@code-collective/booking-widget 1.0.7 → 1.0.9
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/README.md +33 -2
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +1038 -795
- package/dist/booking-widget.min.js +23 -9
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- package/src/lib/CartBar.svelte +4 -11
- package/src/lib/CartBarView.svelte +2 -1
- package/src/lib/CartExpiredView.svelte +63 -0
- package/src/lib/CartExpiryGuard.svelte +410 -0
- package/src/lib/CartExpiryGuard.test.ts +331 -0
- package/src/lib/CartExpiryWatcher.svelte +46 -0
- package/src/lib/CartOverviewButton.svelte +6 -17
- package/src/lib/Checkout.svelte +55 -14
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
- package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
- package/src/lib/CheckoutModal.svelte +805 -560
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/EditBookingView.svelte +15 -4
- package/src/lib/PaymentPage.svelte +78 -61
- package/src/lib/ResultView.svelte +43 -3
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/TicketConfigurator.svelte +25 -3
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +89 -2
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +23 -6
- package/src/lib/client-types.ts +6 -0
- package/src/lib/elements/bw-cart.svelte +3 -12
- package/src/lib/elements/bw-checkout.svelte +9 -16
- package/src/lib/elements/bw-configurator.svelte +3 -13
- package/src/lib/elements/register.ts +33 -10
- package/src/lib/generated-types.ts +133 -3
- package/src/lib/index.ts +19 -28
- package/src/lib/messages.ts +75 -1
- package/src/lib/payment-attempt.ts +56 -0
- package/src/lib/test/fixtures.ts +107 -0
- package/src/lib/test/messages-mock.ts +34 -0
package/src/lib/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { CheckoutCartDetailDto } from './client-types';
|
|
|
6
6
|
import { ApiClient, ApiError } from './api';
|
|
7
7
|
import { SessionManager } from './session-manager';
|
|
8
8
|
import { CartManager } from './cart-manager';
|
|
9
|
+
import { onWidgetMessage } from './messages';
|
|
9
10
|
import TicketConfiguratorComponent from './TicketConfigurator.svelte';
|
|
10
11
|
import CheckoutComponent from './Checkout.svelte';
|
|
11
12
|
import CartOverviewComponent from './CartOverview.svelte';
|
|
@@ -35,6 +36,10 @@ interface BaseConfig {
|
|
|
35
36
|
// consumer build a custom cart summary (item count, remaining time, item details) without calling the
|
|
36
37
|
// checkout API directly.
|
|
37
38
|
onCartUpdated?: (cart: CheckoutCartDetailDto | null) => void;
|
|
39
|
+
// Fires when the cart's own clock ran out (or, for mountCheckout, when it loads onto a cart the server has
|
|
40
|
+
// already let go) - see messages.ts's own cart:expired doc comment for why this is narrower than an
|
|
41
|
+
// onCartUpdated(null) call, which a manual clear can also produce.
|
|
42
|
+
onCartExpired?: () => void;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export interface ConfiguratorConfig extends BaseConfig {
|
|
@@ -96,12 +101,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
96
101
|
});
|
|
97
102
|
|
|
98
103
|
// Listen for postMessage events and forward to callbacks
|
|
99
|
-
|
|
100
|
-
let d: Record<string, unknown>;
|
|
101
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
102
|
-
catch { return; }
|
|
103
|
-
if (!d?.type) return;
|
|
104
|
-
|
|
104
|
+
const stopListening = onWidgetMessage((d) => {
|
|
105
105
|
if (d.type === 'cart:change' && config.onCartChange) {
|
|
106
106
|
config.onCartChange({
|
|
107
107
|
itemCount: (d.itemCount as number) ?? 0,
|
|
@@ -112,12 +112,12 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
112
112
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
113
113
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
116
|
+
});
|
|
117
117
|
|
|
118
118
|
return {
|
|
119
119
|
destroy() {
|
|
120
|
-
|
|
120
|
+
stopListening();
|
|
121
121
|
sessionManager.stop();
|
|
122
122
|
unmount(component);
|
|
123
123
|
},
|
|
@@ -125,7 +125,7 @@ export async function mountConfigurator(target: HTMLElement, config: Configurato
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
export async function mountCheckout(target: HTMLElement, config: CheckoutConfig): Promise<MountedWidget> {
|
|
128
|
-
const { api, sessionManager } = bootstrap(config);
|
|
128
|
+
const { api, sessionManager, cartManager } = bootstrap(config);
|
|
129
129
|
|
|
130
130
|
await sessionManager.ensureSession(config.checkoutKey ?? '');
|
|
131
131
|
|
|
@@ -133,18 +133,14 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
|
|
|
133
133
|
target,
|
|
134
134
|
props: {
|
|
135
135
|
api,
|
|
136
|
+
cartManager,
|
|
136
137
|
wizardPages: config.wizardPages,
|
|
137
138
|
editPages: config.editPages,
|
|
138
139
|
autoSelectSingleTimeSlot: config.autoSelectSingleTimeSlot ?? false,
|
|
139
140
|
},
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
let d: Record<string, unknown>;
|
|
144
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
145
|
-
catch { return; }
|
|
146
|
-
if (!d?.type) return;
|
|
147
|
-
|
|
143
|
+
const stopListening = onWidgetMessage((d) => {
|
|
148
144
|
if (d.type === 'modal:close' && config.onClose) config.onClose();
|
|
149
145
|
if (d.type === 'order:complete' && config.onOrderConfirmed) {
|
|
150
146
|
config.onOrderConfirmed({
|
|
@@ -156,12 +152,12 @@ export async function mountCheckout(target: HTMLElement, config: CheckoutConfig)
|
|
|
156
152
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
157
153
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
158
154
|
}
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
156
|
+
});
|
|
161
157
|
|
|
162
158
|
return {
|
|
163
159
|
destroy() {
|
|
164
|
-
|
|
160
|
+
stopListening();
|
|
165
161
|
sessionManager.stop();
|
|
166
162
|
unmount(component);
|
|
167
163
|
},
|
|
@@ -182,22 +178,17 @@ export async function mountCartOverview(target: HTMLElement, config: CartOvervie
|
|
|
182
178
|
},
|
|
183
179
|
});
|
|
184
180
|
|
|
185
|
-
|
|
186
|
-
let d: Record<string, unknown>;
|
|
187
|
-
try { d = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
|
|
188
|
-
catch { return; }
|
|
189
|
-
if (!d?.type) return;
|
|
190
|
-
|
|
181
|
+
const stopListening = onWidgetMessage((d) => {
|
|
191
182
|
if (d.type === 'modal:open' && config.onCheckout) config.onCheckout();
|
|
192
183
|
if (d.type === 'cart:updated' && 'cart' in d) {
|
|
193
184
|
config.onCartUpdated?.(d.cart as CheckoutCartDetailDto | null);
|
|
194
185
|
}
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
if (d.type === 'cart:expired') config.onCartExpired?.();
|
|
187
|
+
});
|
|
197
188
|
|
|
198
189
|
return {
|
|
199
190
|
destroy() {
|
|
200
|
-
|
|
191
|
+
stopListening();
|
|
201
192
|
sessionManager.stop();
|
|
202
193
|
unmount(component);
|
|
203
194
|
},
|
package/src/lib/messages.ts
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
+
// The widget's own components talk to each other over postMessage - a cart added in bw-configurator has to
|
|
2
|
+
// reach the bar in bw-cart and the modal in bw-checkout, which are separate custom elements with no shared
|
|
3
|
+
// Svelte tree. That makes every one of these messages same-window and same-origin, and the guards below exist
|
|
4
|
+
// to keep it that way.
|
|
5
|
+
|
|
6
|
+
/** Messages this widget sends itself. Anything else on the wire is not ours. */
|
|
7
|
+
const WIDGET_MESSAGE_TYPES = new Set([
|
|
8
|
+
'cart:change',
|
|
9
|
+
'cart:updated',
|
|
10
|
+
// The cart the widget was holding onto is gone because its clock ran out - a strict subset of
|
|
11
|
+
// cart:updated's own cart:null case (see that message's own callers), fired alongside it so a host that
|
|
12
|
+
// wants to react to expiry specifically (analytics, a redirect, its own message) does not have to infer it
|
|
13
|
+
// from an absent cart, which cart:updated also carries for an ordinary manual clear.
|
|
14
|
+
'cart:expired',
|
|
15
|
+
'modal:open',
|
|
16
|
+
'modal:close',
|
|
17
|
+
'order:complete',
|
|
18
|
+
// A Peach checkout is open for the cart / has been abandoned so the cart is Open again. Posted by
|
|
19
|
+
// CheckoutModal for CartExpiryGuard, which keeps prompting but stops acting on the deadline itself while
|
|
20
|
+
// the server exempts the cart from expiry.
|
|
21
|
+
'payment:started',
|
|
22
|
+
'payment:ended',
|
|
23
|
+
// The cart's one clock ran out while a Peach checkout was open. Posted by CartExpiryGuard for
|
|
24
|
+
// CheckoutModal, which tears the attempt down (Peach's real status permitting) so the shopper sees the
|
|
25
|
+
// cart expire there too, rather than an ever-open card form for a cart that is already gone.
|
|
26
|
+
'payment:timed-out',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sends a widget message.
|
|
31
|
+
*
|
|
32
|
+
* Targets this window's own origin rather than '*'. These payloads carry `cart`, and CheckoutCartDetailDto
|
|
33
|
+
* includes `cartToken` - a bearer credential that authorises reading, modifying and paying for the cart. With
|
|
34
|
+
* '*' that went to whatever origin happened to be framing the widget, which in the standalone iframe build is
|
|
35
|
+
* not necessarily anyone we trust.
|
|
36
|
+
*/
|
|
1
37
|
export function postMessage(data: Record<string, unknown>): void {
|
|
2
|
-
window.parent?.postMessage(JSON.stringify(data),
|
|
38
|
+
window.parent?.postMessage(JSON.stringify(data), window.location.origin);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Subscribes to widget messages, ignoring anything that did not come from this widget.
|
|
43
|
+
*
|
|
44
|
+
* The elements mount directly into the merchant's own document (shadow: 'none'), so these listeners sit on
|
|
45
|
+
* the top-level window of a third-party page. Without this check, any iframe already on that page - an ad, a
|
|
46
|
+
* chat widget, a tag manager - could reach them with window.parent.postMessage and drive the widget: spoof
|
|
47
|
+
* the total on the payment consent screen, fire a forged order confirmation into the merchant's analytics, or
|
|
48
|
+
* close the modal mid-confirm on a card that has already been charged.
|
|
49
|
+
*
|
|
50
|
+
* Checking `source` is what does the real work: a message from another frame carries that frame's own window,
|
|
51
|
+
* never ours, and it cannot be spoofed. The origin check is belt-and-braces for the same-window case.
|
|
52
|
+
*/
|
|
53
|
+
export function onWidgetMessage(handler: (data: Record<string, unknown>) => void): () => void {
|
|
54
|
+
function listener(e: MessageEvent) {
|
|
55
|
+
if (e.source !== window || e.origin !== window.location.origin) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let data: Record<string, unknown>;
|
|
60
|
+
try {
|
|
61
|
+
data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Anything without one of our own type values is someone else's traffic sharing this window - a library
|
|
67
|
+
// or the host page talking to itself - not something to hand to a widget handler.
|
|
68
|
+
if (typeof data?.type !== 'string' || !WIDGET_MESSAGE_TYPES.has(data.type)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handler(data);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
window.addEventListener('message', listener);
|
|
76
|
+
return () => window.removeEventListener('message', listener);
|
|
3
77
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The Peach checkout currently open for the cart, persisted so a page reload mid-payment does not lose it.
|
|
2
|
+
//
|
|
3
|
+
// Once payCart has run, the server holds the cart in AwaitingPaymentConfirmation: exempt from expiry, refusing
|
|
4
|
+
// edits and a second payCart alike, until Peach's webhook settles the attempt or the widget abandons it with
|
|
5
|
+
// the checkoutId it was given. Before this, that id lived only in CheckoutModal's component state, so a reload
|
|
6
|
+
// left the shopper with a cart nothing could act on and a guard that could not tell it was locked. Same store
|
|
7
|
+
// and same lifetime as the cart token itself (see cart-manager.ts for why sessionStorage) - it is only ever
|
|
8
|
+
// meaningful for the cart it was minted against, which is why the token is recorded with it.
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = 'morii-checkout-payment';
|
|
11
|
+
|
|
12
|
+
const storage: Storage | null = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
|
|
13
|
+
|
|
14
|
+
export interface PaymentAttempt {
|
|
15
|
+
checkoutId: string;
|
|
16
|
+
entityId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface StoredAttempt extends PaymentAttempt {
|
|
20
|
+
cartToken: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function rememberPaymentAttempt(cartToken: string, attempt: PaymentAttempt): void {
|
|
24
|
+
const stored: StoredAttempt = { cartToken, ...attempt };
|
|
25
|
+
try {
|
|
26
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));
|
|
27
|
+
} catch {
|
|
28
|
+
// storage unavailable (e.g. iframe sandbox, or a browser blocking site data)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The attempt recorded for this cart, if any. One recorded for a different cart is stale - that cart is gone,
|
|
33
|
+
// and the attempt with it - so it is dropped here rather than left to confuse the next reader.
|
|
34
|
+
export function recallPaymentAttempt(cartToken: string): PaymentAttempt | null {
|
|
35
|
+
try {
|
|
36
|
+
const raw = storage?.getItem(STORAGE_KEY);
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
|
|
39
|
+
const stored: StoredAttempt = JSON.parse(raw);
|
|
40
|
+
if (!cartToken || stored.cartToken !== cartToken || !stored.checkoutId || !stored.entityId) {
|
|
41
|
+
forgetPaymentAttempt();
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { checkoutId: stored.checkoutId, entityId: stored.entityId };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function forgetPaymentAttempt(): void {
|
|
51
|
+
try {
|
|
52
|
+
storage?.removeItem(STORAGE_KEY);
|
|
53
|
+
} catch {
|
|
54
|
+
// storage unavailable
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import type { BookingApi } from '../api';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import type { CartManager } from '../cart-manager';
|
|
5
|
+
import type { CheckoutCartDetailDto, CheckoutProductDto } from '../client-types';
|
|
6
|
+
|
|
7
|
+
export const CART_TOKEN = 'cart-token-1';
|
|
8
|
+
|
|
9
|
+
/** A one-item cart whose idle deadline is `msFromNow` away and whose ceiling is 35 minutes from creation. */
|
|
10
|
+
export function buildCart(msFromNow: number, options: { ceilingMsFromNow?: number } = {}): CheckoutCartDetailDto {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
return {
|
|
13
|
+
cartToken: CART_TOKEN,
|
|
14
|
+
issuedAt: new Date(now - 60_000).toISOString(),
|
|
15
|
+
idleExpiresAt: new Date(now + msFromNow).toISOString(),
|
|
16
|
+
absoluteExpiresAt: new Date(now + (options.ceilingMsFromNow ?? 35 * 60_000)).toISOString(),
|
|
17
|
+
items: [
|
|
18
|
+
{
|
|
19
|
+
id: 'item-1',
|
|
20
|
+
bookingUuid: 'booking-1',
|
|
21
|
+
productId: 'product-1',
|
|
22
|
+
optionId: 'option-1',
|
|
23
|
+
unitItems: [{ unitId: 'adult' }],
|
|
24
|
+
availabilityId: '2026-10-01T09:00:00+02:00',
|
|
25
|
+
amount: 1000,
|
|
26
|
+
currencyCode: 'ZAR',
|
|
27
|
+
currencyPrecision: 2,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const product: CheckoutProductDto = {
|
|
34
|
+
id: 'product-1',
|
|
35
|
+
title: 'City Tour Pass',
|
|
36
|
+
options: [
|
|
37
|
+
{
|
|
38
|
+
id: 'option-1',
|
|
39
|
+
title: 'Day ticket',
|
|
40
|
+
units: [{ id: 'adult', title: 'Adult', pricing: [{ retail: 1000, currency: 'ZAR', currencyPrecision: 2 }] }],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
} as unknown as CheckoutProductDto;
|
|
44
|
+
|
|
45
|
+
export function apiError(status: number, code?: string): ApiError {
|
|
46
|
+
return new ApiError(status, code ? { error: code, errorMessage: code } : undefined);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every BookingApi member as a vi.fn, with the cart-shaped ones answering sensibly for a live cart by
|
|
51
|
+
* default. Tests override the one or two members a scenario turns on with mockResolvedValue/
|
|
52
|
+
* mockRejectedValue/mockImplementation.
|
|
53
|
+
*/
|
|
54
|
+
export function fakeApi(cart: CheckoutCartDetailDto): BookingApi & { [K in keyof BookingApi]: BookingApi[K] } {
|
|
55
|
+
return {
|
|
56
|
+
sessionToken: 'session-token-1',
|
|
57
|
+
cartToken: CART_TOKEN,
|
|
58
|
+
startSession: vi.fn(),
|
|
59
|
+
refreshSession: vi.fn(),
|
|
60
|
+
getProduct: vi.fn().mockResolvedValue(product),
|
|
61
|
+
getAvailabilityCalendar: vi.fn(),
|
|
62
|
+
getAvailability: vi.fn(),
|
|
63
|
+
createCart: vi.fn(),
|
|
64
|
+
getCart: vi.fn().mockResolvedValue(cart),
|
|
65
|
+
addCartItem: vi.fn(),
|
|
66
|
+
updateCartItem: vi.fn(),
|
|
67
|
+
removeCartItem: vi.fn(),
|
|
68
|
+
payCart: vi.fn(),
|
|
69
|
+
confirmCart: vi.fn(),
|
|
70
|
+
extendCart: vi.fn().mockResolvedValue(cart),
|
|
71
|
+
abandonPayment: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fakeCartManager(hasCart = true): CartManager {
|
|
76
|
+
return {
|
|
77
|
+
hasCart,
|
|
78
|
+
cartToken: CART_TOKEN,
|
|
79
|
+
reset: vi.fn(),
|
|
80
|
+
ensureCart: vi.fn(),
|
|
81
|
+
} as unknown as CartManager;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A stand-in for Peach's embedded Checkout SDK global, so PaymentPage mounts for real and the test can drive
|
|
86
|
+
* Peach's own callback contract - the handlers PaymentPage registers are captured here.
|
|
87
|
+
*/
|
|
88
|
+
export function installFakePeachSdk(): { handlers: () => Record<string, () => void>; unmount: ReturnType<typeof vi.fn> } {
|
|
89
|
+
let captured: Record<string, () => void> = {};
|
|
90
|
+
const unmount = vi.fn();
|
|
91
|
+
(window as unknown as { Checkout: unknown }).Checkout = {
|
|
92
|
+
initiate: (options: { eventHandlers: Record<string, () => void> }) => {
|
|
93
|
+
captured = options.eventHandlers;
|
|
94
|
+
return { render: vi.fn(), unmount };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return { handlers: () => captured, unmount };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Flushes Svelte's effect queue and any microtask chain a component kicked off. */
|
|
101
|
+
export async function settle(): Promise<void> {
|
|
102
|
+
const { tick } = await import('svelte');
|
|
103
|
+
for (let i = 0; i < 5; i++) {
|
|
104
|
+
await tick();
|
|
105
|
+
await Promise.resolve();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A synchronous, in-memory stand-in for messages.ts, installed with `vi.mock('./messages', ...)` by tests
|
|
2
|
+
// that mount the widget's components. The real module rides window.postMessage, which is asynchronous and
|
|
3
|
+
// which jsdom delivers without the `source`/`origin` the real onWidgetMessage guards on - so under jsdom the
|
|
4
|
+
// components would never hear each other. This keeps the exact same contract (postMessage in, every
|
|
5
|
+
// subscribed handler out) but delivers inline, and records everything posted so a test can assert on it.
|
|
6
|
+
|
|
7
|
+
type Handler = (data: Record<string, unknown>) => void;
|
|
8
|
+
|
|
9
|
+
const handlers = new Set<Handler>();
|
|
10
|
+
|
|
11
|
+
/** Every message posted since the last reset, oldest first. */
|
|
12
|
+
export const posted: Record<string, unknown>[] = [];
|
|
13
|
+
|
|
14
|
+
export function postMessage(data: Record<string, unknown>): void {
|
|
15
|
+
posted.push(data);
|
|
16
|
+
for (const handler of [...handlers]) {
|
|
17
|
+
handler(data);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function onWidgetMessage(handler: Handler): () => void {
|
|
22
|
+
handlers.add(handler);
|
|
23
|
+
return () => handlers.delete(handler);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resetMessages(): void {
|
|
27
|
+
handlers.clear();
|
|
28
|
+
posted.length = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The types of every message posted so far, in order - the usual thing a test wants to assert on. */
|
|
32
|
+
export function postedTypes(): string[] {
|
|
33
|
+
return posted.map((m) => String(m.type));
|
|
34
|
+
}
|