fontdue-js 3.5.1 → 3.7.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/CHANGELOG.md +19 -0
- package/README.md +2 -0
- package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
- package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +1 -1
- package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.d.ts +24 -0
- package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.js +71 -0
- package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.d.ts +30 -0
- package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.js +98 -0
- package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
- package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
- package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.d.ts +3 -1
- package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.js +1 -1
- package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
- package/dist/__generated__/PriceBar_node.graphql.js +23 -16
- package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
- package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
- package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
- package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
- package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +4 -9
- package/dist/__generated__/StoreModalIndexQuery.graphql.js +90 -53
- package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
- package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
- package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +4 -1
- package/dist/__generated__/StoreModalProductContent_collection.graphql.js +12 -5
- package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.d.ts +2 -1
- package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +27 -24
- package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +2 -19
- package/dist/__generated__/StoreModalProductQuery.graphql.js +143 -174
- package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
- package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +54 -53
- package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +38 -0
- package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +750 -0
- package/dist/__generated__/StoreModalProductViewer.graphql.d.ts +34 -0
- package/dist/__generated__/StoreModalProductViewer.graphql.js +149 -0
- package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
- package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.js +1 -1
- package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +3 -1
- package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +1 -1
- package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
- package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
- package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
- package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
- package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
- package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
- package/dist/__generated__/useCurrentCustomerQuery.graphql.d.ts +23 -0
- package/dist/__generated__/{ServerConfigProviderQuery.graphql.js → useCurrentCustomerQuery.graphql.js} +24 -39
- package/dist/__tests__/bundleSelectionOrder.test.js +92 -0
- package/dist/__tests__/collectionBundleSelection.test.js +441 -1446
- package/dist/__tests__/customerSession.test.js +456 -0
- package/dist/__tests__/infiniteScroll.test.js +180 -0
- package/dist/__tests__/licenseExclusions.test.js +2 -1
- package/dist/__tests__/storeModalRoutes.test.js +118 -0
- package/dist/__tests__/storefrontEvents.test.js +167 -0
- package/dist/__tests__/stripeElements.test.js +173 -0
- package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
- package/dist/__tests__/typeTesterInitialSize.test.js +82 -0
- package/dist/__tests__/typeTesterTruncate.test.js +218 -0
- package/dist/auth/session.d.ts +42 -0
- package/dist/auth/session.js +139 -0
- package/dist/auth/tokenStore.d.ts +61 -0
- package/dist/auth/tokenStore.js +162 -0
- package/dist/components/Cart/Checkout.js +19 -9
- package/dist/components/Cart/storefrontEvents.d.ts +34 -0
- package/dist/components/Cart/storefrontEvents.js +138 -0
- package/dist/components/ConfigContext.d.ts +2 -2
- package/dist/components/CustomerSignIn/index.d.ts +33 -0
- package/dist/components/CustomerSignIn/index.js +170 -0
- package/dist/components/FontFamilies/index.js +4 -3
- package/dist/components/NewsletterSignup/index.js +5 -1
- package/dist/components/Precart/index.js +6 -0
- package/dist/components/PriceBar/index.js +5 -3
- package/dist/components/Root/index.js +4 -34
- package/dist/components/Root/productState.d.ts +2 -8
- package/dist/components/Root/productState.js +46 -57
- package/dist/components/SKUPrice/index.js +7 -8
- package/dist/components/StoreModal/StoreModalFamily.js +4 -2
- package/dist/components/StoreModal/StoreModalIndex.js +48 -7
- package/dist/components/StoreModal/StoreModalProduct.d.ts +5 -1
- package/dist/components/StoreModal/StoreModalProduct.js +69 -16
- package/dist/components/StoreModal/index.js +14 -14
- package/dist/components/StoreModal/routes.js +9 -1
- package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
- package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
- package/dist/components/StoreModalProductLicenseSelection/index.js +3 -2
- package/dist/components/TestFontsForm/index.js +5 -1
- package/dist/components/TypeTester/TypeTesterContent.js +9 -2
- package/dist/components/TypeTester/TypeTesterContext.js +17 -4
- package/dist/components/TypeTester/TypeTesterFitter.js +7 -2
- package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -0
- package/dist/components/TypeTester/TypeTesterStandalone.js +2 -0
- package/dist/components/TypeTester/TypeTesterStandaloneElement.d.ts +1 -0
- package/dist/components/TypeTester/TypeTesterStandaloneElement.js +4 -1
- package/dist/components/TypeTester/TypeTesterVariableAxes.js +16 -10
- package/dist/components/TypeTester/index.d.ts +3 -1
- package/dist/components/TypeTester/index.js +2 -1
- package/dist/components/TypeTester/truncate.d.ts +12 -0
- package/dist/components/TypeTester/truncate.js +95 -0
- package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
- package/dist/components/TypeTester/useTypeTesterStyler.js +36 -7
- package/dist/components/TypeTesters/TypeTestersElement.d.ts +2 -1
- package/dist/components/TypeTesters/TypeTestersElement.js +4 -1
- package/dist/components/TypeTesters/index.d.ts +2 -0
- package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
- package/dist/components/elements/StoreModalContainer/index.js +9 -4
- package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
- package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
- package/dist/components/elements/StoreModalUnifiedCheckout.d.ts +1 -1
- package/dist/components/elements/StoreModalUnifiedCheckout.js +20 -17
- package/dist/components/useInfiniteScroll.d.ts +28 -0
- package/dist/components/useInfiniteScroll.js +50 -0
- package/dist/fontdue.css +63 -0
- package/dist/hooks/useAutofit.d.ts +2 -1
- package/dist/hooks/useAutofit.js +10 -4
- package/dist/hooks/useCurrentCustomer.d.ts +34 -0
- package/dist/hooks/useCurrentCustomer.js +93 -0
- package/dist/reducer.d.ts +5 -5
- package/dist/reducer.js +9 -87
- package/dist/relay/environment.js +91 -50
- package/dist/selection.d.ts +40 -0
- package/dist/selection.js +494 -0
- package/dist/storeModalRoutes.d.ts +38 -0
- package/dist/storeModalRoutes.js +110 -0
- package/dist/stripeElements.d.ts +18 -0
- package/dist/stripeElements.js +49 -0
- package/dist/utils.d.ts +1 -12
- package/dist/utils.js +1 -36
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/__generated__/NodePasswordFormIDQuery.graphql.d.ts +0 -21
- package/dist/__generated__/NodePasswordFormIDQuery.graphql.js +0 -86
- package/dist/__generated__/NodePasswordFormSlugQuery.graphql.d.ts +0 -25
- package/dist/__generated__/NodePasswordFormSlugQuery.graphql.js +0 -108
- package/dist/__generated__/ServerConfigProviderQuery.graphql.d.ts +0 -24
- package/dist/__generated__/TestModeBannerQuery.graphql.d.ts +0 -20
- package/dist/__generated__/TestModeBannerQuery.graphql.js +0 -87
- package/dist/__generated__/ThemeConfigQuery.graphql.d.ts +0 -20
- package/dist/__generated__/ThemeConfigQuery.graphql.js +0 -87
- package/dist/components/CartButton/index.server.d.ts +0 -3
- package/dist/components/CartButton/index.server.js +0 -18
- package/dist/components/CorsErrorModal.d.ts +0 -1
- package/dist/components/CorsErrorModal.js +0 -92
- package/dist/components/FontdueProvider/FontdueProviderClientComponent.d.ts +0 -14
- package/dist/components/FontdueProvider/FontdueProviderClientComponent.js +0 -61
- package/dist/components/ThemeConfig/index.server.d.ts +0 -2
- package/dist/components/ThemeConfig/index.server.js +0 -17
- package/dist/components/TypeTester/features.d.ts +0 -5
- package/dist/components/TypeTester/features.js +0 -143
- package/dist/data/unicodeData.d.ts +0 -4
- package/dist/data/unicodeData.js +0 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The customer session: where the tokens live, and what the network layer does
|
|
3
|
+
* with them.
|
|
4
|
+
*
|
|
5
|
+
* Every part of this is browser-only by design — on the server the store
|
|
6
|
+
* reports "signed out", so one visitor's session can never be rendered into
|
|
7
|
+
* another's page. Rather than a DOM environment, the browser is stubbed with
|
|
8
|
+
* exactly what the code touches (`window.localStorage`), which lets the same
|
|
9
|
+
* file also test the server case by leaving it unstubbed. Both modules read
|
|
10
|
+
* `typeof window` once at load, so the stub goes up before the dynamic import.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
vi.unstubAllEnvs();
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** A memory-backed `window.localStorage`, or one that refuses to store. */
|
|
21
|
+
function browser() {
|
|
22
|
+
let {
|
|
23
|
+
throws = false
|
|
24
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
25
|
+
const data = new Map();
|
|
26
|
+
const guard = () => {
|
|
27
|
+
if (throws) throw new Error('SecurityError');
|
|
28
|
+
};
|
|
29
|
+
const localStorage = {
|
|
30
|
+
getItem: key => {
|
|
31
|
+
guard();
|
|
32
|
+
return data.get(key) ?? null;
|
|
33
|
+
},
|
|
34
|
+
setItem: (key, value) => {
|
|
35
|
+
guard();
|
|
36
|
+
data.set(key, value);
|
|
37
|
+
},
|
|
38
|
+
removeItem: key => {
|
|
39
|
+
guard();
|
|
40
|
+
data.delete(key);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// `addEventListener` is not used by the session code, but the modules under
|
|
45
|
+
// test import corsError, which registers a `beforeunload` listener at load.
|
|
46
|
+
vi.stubGlobal('window', {
|
|
47
|
+
localStorage,
|
|
48
|
+
addEventListener: () => {},
|
|
49
|
+
removeEventListener: () => {}
|
|
50
|
+
});
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
const request = {
|
|
54
|
+
name: 'TestQuery',
|
|
55
|
+
text: 'query TestQuery { viewer { id } }'
|
|
56
|
+
};
|
|
57
|
+
const tokens = {
|
|
58
|
+
accessToken: 'access-1',
|
|
59
|
+
refreshToken: 'refresh-1'
|
|
60
|
+
};
|
|
61
|
+
async function tokenStore() {
|
|
62
|
+
const store = await import("../auth/tokenStore.js");
|
|
63
|
+
store.resetTokenStore();
|
|
64
|
+
return store;
|
|
65
|
+
}
|
|
66
|
+
function stored(data, url) {
|
|
67
|
+
const raw = data.get(`fontdue:auth:${url}`);
|
|
68
|
+
return raw == null ? null : JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
function ok(body) {
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
status: 200,
|
|
74
|
+
json: async () => body
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The mocked endpoint answers whichever operation it is handed, so one mock
|
|
79
|
+
// covers a query, the refresh mutation it provokes, and the retry.
|
|
80
|
+
function endpoint(responses) {
|
|
81
|
+
const queries = [...responses.query];
|
|
82
|
+
|
|
83
|
+
// The `Authorization` header as each request actually went out. It has to be
|
|
84
|
+
// read here rather than from `mock.calls` afterwards: the fetch options are
|
|
85
|
+
// one object reused across the retry, so renewing the token rewrites the
|
|
86
|
+
// header the first call appears to have been made with.
|
|
87
|
+
const sent = [];
|
|
88
|
+
const mock = vi.fn(async (_url, init) => {
|
|
89
|
+
var _init$headers;
|
|
90
|
+
sent.push((_init$headers = init.headers) === null || _init$headers === void 0 ? void 0 : _init$headers.Authorization);
|
|
91
|
+
const body = JSON.parse(String(init.body));
|
|
92
|
+
if (body.query.includes('FontdueRefreshAccessToken')) {
|
|
93
|
+
return ok(responses.refresh ?? {
|
|
94
|
+
data: {
|
|
95
|
+
refreshAccessToken: null
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return ok(queries.shift() ?? {
|
|
100
|
+
data: {}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return Object.assign(mock, {
|
|
104
|
+
sent
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
describe('token store', () => {
|
|
108
|
+
it('persists tokens under a key naming the Fontdue site', async () => {
|
|
109
|
+
const data = browser();
|
|
110
|
+
const store = await tokenStore();
|
|
111
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
112
|
+
expect(store.tokenStorageKey('https://acme.fontdue.com')).toBe('fontdue:auth:https://acme.fontdue.com');
|
|
113
|
+
expect(stored(data, 'https://acme.fontdue.com')).toEqual(tokens);
|
|
114
|
+
});
|
|
115
|
+
it('reads tokens a previous page load stored', async () => {
|
|
116
|
+
const data = browser();
|
|
117
|
+
data.set('fontdue:auth:https://acme.fontdue.com', JSON.stringify(tokens));
|
|
118
|
+
const store = await tokenStore();
|
|
119
|
+
expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
|
|
120
|
+
});
|
|
121
|
+
it('keeps two foundries apart, and ignores a trailing slash or casing', async () => {
|
|
122
|
+
browser();
|
|
123
|
+
const store = await tokenStore();
|
|
124
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
125
|
+
|
|
126
|
+
// Same site, spelled differently — one session, not three.
|
|
127
|
+
expect(store.getTokens('https://acme.fontdue.com/')).toEqual(tokens);
|
|
128
|
+
expect(store.getTokens('https://ACME.fontdue.com')).toEqual(tokens);
|
|
129
|
+
|
|
130
|
+
// A different foundry must never be handed this token.
|
|
131
|
+
expect(store.getTokens('https://other.fontdue.com')).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
it('signs out everywhere, storage included', async () => {
|
|
134
|
+
const data = browser();
|
|
135
|
+
const store = await tokenStore();
|
|
136
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
137
|
+
store.clearTokens('https://acme.fontdue.com');
|
|
138
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
139
|
+
expect(stored(data, 'https://acme.fontdue.com')).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
it('tells subscribers, which is how one island hears about another', async () => {
|
|
142
|
+
browser();
|
|
143
|
+
const store = await tokenStore();
|
|
144
|
+
const listener = vi.fn();
|
|
145
|
+
const unsubscribe = store.subscribeToTokens(listener);
|
|
146
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
147
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
148
|
+
store.clearTokens('https://acme.fontdue.com');
|
|
149
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
150
|
+
unsubscribe();
|
|
151
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
152
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
it('survives a browser that refuses to store anything', async () => {
|
|
155
|
+
// Private windows, blocked site data and partitioned third-party storage
|
|
156
|
+
// all throw here rather than returning null. The session should last the
|
|
157
|
+
// tab rather than taking the page down.
|
|
158
|
+
browser({
|
|
159
|
+
throws: true
|
|
160
|
+
});
|
|
161
|
+
const store = await tokenStore();
|
|
162
|
+
expect(() => store.setTokens(tokens, 'https://acme.fontdue.com')).not.toThrow();
|
|
163
|
+
// Held in memory, so this tab stays signed in even though nothing persisted.
|
|
164
|
+
expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
|
|
165
|
+
});
|
|
166
|
+
it('ignores a stored value that is not a token pair', async () => {
|
|
167
|
+
const data = browser();
|
|
168
|
+
data.set('fontdue:auth:https://acme.fontdue.com', 'not json');
|
|
169
|
+
let store = await tokenStore();
|
|
170
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
171
|
+
data.set('fontdue:auth:https://acme.fontdue.com', JSON.stringify({
|
|
172
|
+
accessToken: 'only-half'
|
|
173
|
+
}));
|
|
174
|
+
store = await tokenStore();
|
|
175
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
it('reports signed out on the server, whatever is stored', async () => {
|
|
178
|
+
// No `window` stub: this is the SSR pass. Returning a token here would put
|
|
179
|
+
// one visitor's session into another's prerendered page, and would have the
|
|
180
|
+
// first client render disagree with the server HTML.
|
|
181
|
+
const store = await tokenStore();
|
|
182
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
183
|
+
expect(() => store.setTokens(tokens, 'https://acme.fontdue.com')).not.toThrow();
|
|
184
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('createNetworkFetch (customer session)', () => {
|
|
188
|
+
it('sends no Authorization header when signed out', async () => {
|
|
189
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
190
|
+
browser();
|
|
191
|
+
await tokenStore();
|
|
192
|
+
const fetchMock = endpoint({
|
|
193
|
+
query: [{
|
|
194
|
+
data: {}
|
|
195
|
+
}]
|
|
196
|
+
});
|
|
197
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
198
|
+
const {
|
|
199
|
+
createNetworkFetch
|
|
200
|
+
} = await import("../relay/environment.js");
|
|
201
|
+
await createNetworkFetch()(request, {});
|
|
202
|
+
expect(fetchMock.sent[0]).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
it('sends the access token once the customer is signed in', async () => {
|
|
205
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
206
|
+
browser();
|
|
207
|
+
const store = await tokenStore();
|
|
208
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
209
|
+
const fetchMock = endpoint({
|
|
210
|
+
query: [{
|
|
211
|
+
data: {}
|
|
212
|
+
}]
|
|
213
|
+
});
|
|
214
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
215
|
+
const {
|
|
216
|
+
createNetworkFetch
|
|
217
|
+
} = await import("../relay/environment.js");
|
|
218
|
+
await createNetworkFetch()(request, {});
|
|
219
|
+
expect(fetchMock.sent[0]).toBe('Bearer access-1');
|
|
220
|
+
});
|
|
221
|
+
it('does not let a customer session look like an admin preview', async () => {
|
|
222
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
223
|
+
browser();
|
|
224
|
+
const store = await tokenStore();
|
|
225
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
226
|
+
const fetchMock = endpoint({
|
|
227
|
+
query: [{
|
|
228
|
+
data: {}
|
|
229
|
+
}]
|
|
230
|
+
});
|
|
231
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
232
|
+
const {
|
|
233
|
+
createNetworkFetch
|
|
234
|
+
} = await import("../relay/environment.js");
|
|
235
|
+
await createNetworkFetch()(request, {});
|
|
236
|
+
|
|
237
|
+
// Preview is decided by the marker cookie, never by the presence of an
|
|
238
|
+
// Authorization header — otherwise signing in would reveal unpublished
|
|
239
|
+
// fonts to every customer.
|
|
240
|
+
const init = fetchMock.mock.calls[0][1];
|
|
241
|
+
expect(init.headers['fontdue-preview']).toBe('false');
|
|
242
|
+
});
|
|
243
|
+
it('leaves a forwarded preview token alone', async () => {
|
|
244
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
245
|
+
browser();
|
|
246
|
+
const store = await tokenStore();
|
|
247
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
248
|
+
const fetchMock = endpoint({
|
|
249
|
+
query: [{
|
|
250
|
+
data: {}
|
|
251
|
+
}]
|
|
252
|
+
});
|
|
253
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
254
|
+
const {
|
|
255
|
+
createNetworkFetch
|
|
256
|
+
} = await import("../relay/environment.js");
|
|
257
|
+
await createNetworkFetch({
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: 'Bearer admin-token'
|
|
260
|
+
}
|
|
261
|
+
})(request, {});
|
|
262
|
+
expect(fetchMock.sent[0]).toBe('Bearer admin-token');
|
|
263
|
+
});
|
|
264
|
+
it('renews an expired token and repeats the request', async () => {
|
|
265
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
266
|
+
browser();
|
|
267
|
+
const store = await tokenStore();
|
|
268
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
269
|
+
const fetchMock = endpoint({
|
|
270
|
+
query: [{
|
|
271
|
+
data: {
|
|
272
|
+
viewer: null
|
|
273
|
+
},
|
|
274
|
+
errors: [{
|
|
275
|
+
extensions: {
|
|
276
|
+
code: 'token:expired'
|
|
277
|
+
}
|
|
278
|
+
}]
|
|
279
|
+
}, {
|
|
280
|
+
data: {
|
|
281
|
+
viewer: {
|
|
282
|
+
id: '1'
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}],
|
|
286
|
+
refresh: {
|
|
287
|
+
data: {
|
|
288
|
+
refreshAccessToken: {
|
|
289
|
+
accessToken: 'access-2',
|
|
290
|
+
refreshToken: 'refresh-2'
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
296
|
+
const {
|
|
297
|
+
createNetworkFetch
|
|
298
|
+
} = await import("../relay/environment.js");
|
|
299
|
+
const result = await createNetworkFetch()(request, {});
|
|
300
|
+
|
|
301
|
+
// Query, refresh, query again.
|
|
302
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
303
|
+
expect(fetchMock.sent[0]).toBe('Bearer access-1');
|
|
304
|
+
expect(fetchMock.sent[2]).toBe('Bearer access-2');
|
|
305
|
+
|
|
306
|
+
// The caller sees the successful retry, not the expiry.
|
|
307
|
+
expect(result).toEqual({
|
|
308
|
+
data: {
|
|
309
|
+
viewer: {
|
|
310
|
+
id: '1'
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
expect(store.getTokens('https://acme.fontdue.com')).toEqual({
|
|
315
|
+
accessToken: 'access-2',
|
|
316
|
+
refreshToken: 'refresh-2'
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
it('renews at most once, so a still-expired answer cannot loop', async () => {
|
|
320
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
321
|
+
browser();
|
|
322
|
+
const store = await tokenStore();
|
|
323
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
324
|
+
const expired = {
|
|
325
|
+
data: null,
|
|
326
|
+
errors: [{
|
|
327
|
+
extensions: {
|
|
328
|
+
code: 'token:expired'
|
|
329
|
+
}
|
|
330
|
+
}]
|
|
331
|
+
};
|
|
332
|
+
const fetchMock = endpoint({
|
|
333
|
+
query: [expired, expired],
|
|
334
|
+
refresh: {
|
|
335
|
+
data: {
|
|
336
|
+
refreshAccessToken: {
|
|
337
|
+
accessToken: 'access-2',
|
|
338
|
+
refreshToken: 'refresh-2'
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
344
|
+
const {
|
|
345
|
+
createNetworkFetch
|
|
346
|
+
} = await import("../relay/environment.js");
|
|
347
|
+
await createNetworkFetch()(request, {});
|
|
348
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
349
|
+
});
|
|
350
|
+
it('signs the customer out when the credential will never work again', async () => {
|
|
351
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
352
|
+
browser();
|
|
353
|
+
const store = await tokenStore();
|
|
354
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
355
|
+
const fetchMock = endpoint({
|
|
356
|
+
query: [{
|
|
357
|
+
data: null,
|
|
358
|
+
errors: [{
|
|
359
|
+
extensions: {
|
|
360
|
+
code: 'token:invalid'
|
|
361
|
+
}
|
|
362
|
+
}]
|
|
363
|
+
}]
|
|
364
|
+
});
|
|
365
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
366
|
+
const {
|
|
367
|
+
createNetworkFetch
|
|
368
|
+
} = await import("../relay/environment.js");
|
|
369
|
+
await createNetworkFetch()(request, {});
|
|
370
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
371
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
372
|
+
});
|
|
373
|
+
it('signs the customer out when the refresh token has expired too', async () => {
|
|
374
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
375
|
+
browser();
|
|
376
|
+
const store = await tokenStore();
|
|
377
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
378
|
+
const fetchMock = endpoint({
|
|
379
|
+
query: [{
|
|
380
|
+
data: null,
|
|
381
|
+
errors: [{
|
|
382
|
+
extensions: {
|
|
383
|
+
code: 'token:expired'
|
|
384
|
+
}
|
|
385
|
+
}]
|
|
386
|
+
}],
|
|
387
|
+
refresh: {
|
|
388
|
+
data: {
|
|
389
|
+
refreshAccessToken: null
|
|
390
|
+
},
|
|
391
|
+
errors: [{
|
|
392
|
+
message: 'Invalid'
|
|
393
|
+
}]
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
397
|
+
const {
|
|
398
|
+
createNetworkFetch
|
|
399
|
+
} = await import("../relay/environment.js");
|
|
400
|
+
await createNetworkFetch()(request, {});
|
|
401
|
+
expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
it('keeps the session when the renewal request itself fails', async () => {
|
|
404
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
405
|
+
browser();
|
|
406
|
+
const store = await tokenStore();
|
|
407
|
+
store.setTokens(tokens, 'https://acme.fontdue.com');
|
|
408
|
+
const fetchMock = vi.fn(async (_url, init) => {
|
|
409
|
+
const body = JSON.parse(String(init.body));
|
|
410
|
+
if (body.query.includes('FontdueRefreshAccessToken')) {
|
|
411
|
+
throw new TypeError('Failed to fetch');
|
|
412
|
+
}
|
|
413
|
+
return ok({
|
|
414
|
+
data: null,
|
|
415
|
+
errors: [{
|
|
416
|
+
extensions: {
|
|
417
|
+
code: 'token:expired'
|
|
418
|
+
}
|
|
419
|
+
}]
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
423
|
+
const {
|
|
424
|
+
createNetworkFetch
|
|
425
|
+
} = await import("../relay/environment.js");
|
|
426
|
+
await createNetworkFetch()(request, {});
|
|
427
|
+
|
|
428
|
+
// A flaky network says nothing about the credential — the next request
|
|
429
|
+
// tries again rather than the visitor being signed out by a dropped packet.
|
|
430
|
+
expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
|
|
431
|
+
});
|
|
432
|
+
it('ignores an auth error when nobody is signed in', async () => {
|
|
433
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
434
|
+
browser();
|
|
435
|
+
await tokenStore();
|
|
436
|
+
const fetchMock = endpoint({
|
|
437
|
+
query: [{
|
|
438
|
+
data: null,
|
|
439
|
+
errors: [{
|
|
440
|
+
extensions: {
|
|
441
|
+
code: 'token:missing'
|
|
442
|
+
}
|
|
443
|
+
}]
|
|
444
|
+
}]
|
|
445
|
+
});
|
|
446
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
447
|
+
const {
|
|
448
|
+
createNetworkFetch
|
|
449
|
+
} = await import("../relay/environment.js");
|
|
450
|
+
await createNetworkFetch()(request, {});
|
|
451
|
+
|
|
452
|
+
// No renewal, no retry: the request simply asked for something that needs
|
|
453
|
+
// a customer, and there isn't one.
|
|
454
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act, useRef } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import useInfiniteScroll from '../components/useInfiniteScroll.js';
|
|
6
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
7
|
+
class FakeIntersectionObserver {
|
|
8
|
+
static instances = [];
|
|
9
|
+
observed = [];
|
|
10
|
+
disconnected = false;
|
|
11
|
+
constructor(callback, options) {
|
|
12
|
+
this.callback = callback;
|
|
13
|
+
this.options = options;
|
|
14
|
+
FakeIntersectionObserver.instances.push(this);
|
|
15
|
+
}
|
|
16
|
+
observe(element) {
|
|
17
|
+
this.observed.push(element);
|
|
18
|
+
}
|
|
19
|
+
unobserve() {}
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.disconnected = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Report every observed element as having come into view.
|
|
25
|
+
enter() {
|
|
26
|
+
this.callback(this.observed.map(target => ({
|
|
27
|
+
target,
|
|
28
|
+
isIntersecting: true
|
|
29
|
+
})), this);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const observers = () => FakeIntersectionObserver.instances;
|
|
33
|
+
const live = () => observers().filter(observer => !observer.disconnected);
|
|
34
|
+
|
|
35
|
+
// The shape the store modal index renders: the sentinel stays mounted while a
|
|
36
|
+
// page is in flight (`enabled` false) and only goes away once the list is
|
|
37
|
+
// exhausted, and the scroller is an ancestor whose ref is still null while the
|
|
38
|
+
// sentinel renders.
|
|
39
|
+
function Harness(_ref) {
|
|
40
|
+
let {
|
|
41
|
+
enabled,
|
|
42
|
+
hasMore = true,
|
|
43
|
+
onLoadMore
|
|
44
|
+
} = _ref;
|
|
45
|
+
const scrollContainer = useRef(null);
|
|
46
|
+
return /*#__PURE__*/React.createElement("div", {
|
|
47
|
+
ref: scrollContainer,
|
|
48
|
+
"data-testid": "scroller"
|
|
49
|
+
}, /*#__PURE__*/React.createElement(Sentinel, {
|
|
50
|
+
enabled: enabled,
|
|
51
|
+
hasMore: hasMore,
|
|
52
|
+
onLoadMore: onLoadMore,
|
|
53
|
+
rootRef: scrollContainer
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
function Sentinel(_ref2) {
|
|
57
|
+
let {
|
|
58
|
+
enabled,
|
|
59
|
+
hasMore,
|
|
60
|
+
onLoadMore,
|
|
61
|
+
rootRef
|
|
62
|
+
} = _ref2;
|
|
63
|
+
const sentinelRef = useInfiniteScroll({
|
|
64
|
+
enabled,
|
|
65
|
+
onLoadMore,
|
|
66
|
+
rootRef,
|
|
67
|
+
rootMargin: '0px 0px 600px 0px'
|
|
68
|
+
});
|
|
69
|
+
return hasMore ? /*#__PURE__*/React.createElement("div", {
|
|
70
|
+
ref: sentinelRef,
|
|
71
|
+
"data-testid": "sentinel"
|
|
72
|
+
}) : null;
|
|
73
|
+
}
|
|
74
|
+
let container;
|
|
75
|
+
let root;
|
|
76
|
+
const render = element => {
|
|
77
|
+
act(() => {
|
|
78
|
+
root.render(element);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
FakeIntersectionObserver.instances = [];
|
|
83
|
+
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
|
84
|
+
container = document.createElement('div');
|
|
85
|
+
document.body.appendChild(container);
|
|
86
|
+
root = createRoot(container);
|
|
87
|
+
});
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
act(() => root.unmount());
|
|
90
|
+
container.remove();
|
|
91
|
+
vi.unstubAllGlobals();
|
|
92
|
+
});
|
|
93
|
+
describe('useInfiniteScroll', () => {
|
|
94
|
+
it('loads more when the sentinel comes into view', () => {
|
|
95
|
+
const onLoadMore = vi.fn();
|
|
96
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
97
|
+
enabled: true,
|
|
98
|
+
onLoadMore: onLoadMore
|
|
99
|
+
}));
|
|
100
|
+
expect(live()).toHaveLength(1);
|
|
101
|
+
act(() => live()[0].enter());
|
|
102
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
it('watches the scrolling ancestor, whose ref is only attached after render', () => {
|
|
105
|
+
var _options, _options2;
|
|
106
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
107
|
+
enabled: true,
|
|
108
|
+
onLoadMore: vi.fn()
|
|
109
|
+
}));
|
|
110
|
+
const [observer] = live();
|
|
111
|
+
expect((_options = observer.options) === null || _options === void 0 ? void 0 : _options.root).toBe(container.querySelector('[data-testid="scroller"]'));
|
|
112
|
+
expect((_options2 = observer.options) === null || _options2 === void 0 ? void 0 : _options2.rootMargin).toBe('0px 0px 600px 0px');
|
|
113
|
+
expect(observer.observed).toEqual([container.querySelector('[data-testid="sentinel"]')]);
|
|
114
|
+
});
|
|
115
|
+
it('stops watching while a page is in flight, and picks it up again after', () => {
|
|
116
|
+
const onLoadMore = vi.fn();
|
|
117
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
118
|
+
enabled: true,
|
|
119
|
+
onLoadMore: onLoadMore
|
|
120
|
+
}));
|
|
121
|
+
act(() => live()[0].enter());
|
|
122
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
123
|
+
|
|
124
|
+
// The sentinel is still on screen, but `loadNext` is running: nothing
|
|
125
|
+
// should be watching it, so the load isn't asked for again on every frame.
|
|
126
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
127
|
+
enabled: false,
|
|
128
|
+
onLoadMore: onLoadMore
|
|
129
|
+
}));
|
|
130
|
+
expect(live()).toHaveLength(0);
|
|
131
|
+
|
|
132
|
+
// Page arrived, more to come: back to watching, and still in view.
|
|
133
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
134
|
+
enabled: true,
|
|
135
|
+
onLoadMore: onLoadMore
|
|
136
|
+
}));
|
|
137
|
+
expect(live()).toHaveLength(1);
|
|
138
|
+
act(() => live()[0].enter());
|
|
139
|
+
expect(onLoadMore).toHaveBeenCalledTimes(2);
|
|
140
|
+
});
|
|
141
|
+
it('never observes when there is nothing left to load', () => {
|
|
142
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
143
|
+
enabled: false,
|
|
144
|
+
hasMore: false,
|
|
145
|
+
onLoadMore: vi.fn()
|
|
146
|
+
}));
|
|
147
|
+
expect(observers()).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
it('disconnects when the sentinel is removed', () => {
|
|
150
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
151
|
+
enabled: true,
|
|
152
|
+
onLoadMore: vi.fn()
|
|
153
|
+
}));
|
|
154
|
+
const [observer] = observers();
|
|
155
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
156
|
+
enabled: true,
|
|
157
|
+
hasMore: false,
|
|
158
|
+
onLoadMore: vi.fn()
|
|
159
|
+
}));
|
|
160
|
+
expect(observer.disconnected).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
it('keeps watching when only the callback identity changes', () => {
|
|
163
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
164
|
+
enabled: true,
|
|
165
|
+
onLoadMore: () => {}
|
|
166
|
+
}));
|
|
167
|
+
const [observer] = observers();
|
|
168
|
+
const onLoadMore = vi.fn();
|
|
169
|
+
render( /*#__PURE__*/React.createElement(Harness, {
|
|
170
|
+
enabled: true,
|
|
171
|
+
onLoadMore: onLoadMore
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// Same observer, and it calls the callback the latest render passed.
|
|
175
|
+
expect(observers()).toHaveLength(1);
|
|
176
|
+
expect(observer.disconnected).toBe(false);
|
|
177
|
+
act(() => observer.enter());
|
|
178
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
});
|