diff --git a/.changeset/silly-lamps-flash.md b/.changeset/silly-lamps-flash.md new file mode 100644 index 000000000..82d1ac736 --- /dev/null +++ b/.changeset/silly-lamps-flash.md @@ -0,0 +1,5 @@ +--- +'@lion/ajax': patch +--- + +Fix cache session race condition for in-flight requests diff --git a/.changeset/tall-adults-act.md b/.changeset/tall-adults-act.md new file mode 100644 index 000000000..ed3c4e889 --- /dev/null +++ b/.changeset/tall-adults-act.md @@ -0,0 +1,8 @@ +--- +'@lion/ajax': minor +--- + +**BREAKING** public API changes: + + - Changed `timeToLive` to `maxAge` + - Renamed `requestIdentificationFn` to `requestIdFunction` diff --git a/docs/docs/tools/ajax/features.md b/docs/docs/tools/ajax/features.md index afd094501..033093fe2 100644 --- a/docs/docs/tools/ajax/features.md +++ b/docs/docs/tools/ajax/features.md @@ -15,9 +15,11 @@ const getCacheIdentifier = () => { return userId; }; +const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds + const cacheOptions = { useCache: true, - timeToLive: 1000 * 60 * 10, // 10 minutes + maxAge: TEN_MINUTES, }; const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( @@ -72,9 +74,13 @@ const newUser = await response.json(); ### JSON requests -We usually deal with JSON requests and responses. With `fetchJson` you don't need to specifically stringify the request body or parse the response body. +We usually deal with JSON requests and responses. `ajax.fetchJson` supports JSON by: -The result will have the Response object on `.response` property, and the decoded json will be available on `.body`. +- Serializing request body as JSON +- Deserializing response payload as JSON +- Adding the correct Content-Type and Accept headers + +> Note that, the result will have the Response object on `.response` property, and the parsed JSON will be available on `.body`. ## GET JSON request @@ -133,7 +139,7 @@ export const errorHandling = () => { } } else { // an error happened before receiving a response, - // ex. an incorrect request or network error + // Example: an incorrect request or network error actionLogger.log(error); } } @@ -157,32 +163,28 @@ For IE11 you will need a polyfill for fetch. You should add this on your top lev [This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests) -## Ajax Cache +## Ajax Caching Support -A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in -frontend `services`. +Ajax package provides in-memory cache support through interceptors. And cache interceptors can be added manually or by configuring the Ajax instance. -The **request interceptor**'s main goal is to determine whether or not to -**return the cached object**. This is done based on the options that are being -passed. +The cache request interceptor and cache response interceptor are designed to work together to support caching of network requests/responses. -The **response interceptor**'s goal is to determine **when to cache** the -requested response, based on the options that are being passed. +> The **request interceptor** checks whether the response for this particular request is cached, and if so returns the cached response. +> And the **response interceptor** caches the response for this particular request. ### Getting started Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config, see examples below. -> **Note**: make sure to add the **interceptors** only **once**. This is usually -> done on app-level +> **Note**: make sure to add the **interceptors** only **once**. This is usually done on app-level ```js import { ajax, createCacheInterceptors } from '@lion-web/ajax'; const globalCacheOptions = { useCache: true, - timeToLive: 1000 * 60 * 5, // 5 minutes + maxAge: 1000 * 60 * 5, // 5 minutes }; // Cache is removed each time an identifier changes, @@ -208,7 +210,7 @@ import { Ajax } from '@lion/ajax'; export const ajax = new Ajax({ cacheOptions: { useCache: true, - timeToLive: 1000 * 60 * 5, // 5 minutes + maxAge: 1000 * 60 * 5, // 5 minutes getCacheIdentifier: () => getActiveProfile().profileId, }, }); @@ -218,8 +220,7 @@ export const ajax = new Ajax({ > Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors. -We can see if a response is served from the cache by checking the `response.fromCache` property, -which is either undefined for normal requests, or set to true for responses that were served from cache. +We can see if a response is served from the cache by checking the `response.fromCache` property, which is either undefined for normal requests, or set to true for responses that were served from cache. ```js preview-story export const cache = () => { @@ -284,28 +285,28 @@ export const cacheActionOptions = () => { Invalidating the cache, or cache busting, can be done in multiple ways: -- Going past the `timeToLive` of the cache object +- Going past the `maxAge` of the cache object - Changing cache identifier (e.g. user session or active profile changes) - Doing a non GET request to the cached endpoint - Invalidates the cache of that endpoint - Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex` -## Time to live +## maxAge -In this demo we pass a timeToLive of three seconds. -Try clicking the fetch button and watch fromCache change whenever TTL expires. +In this demo we pass a maxAge of three seconds. +Try clicking the fetch button and watch fromCache change whenever maxAge expires. -After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests. +After maxAge expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests. ```js preview-story -export const cacheTimeToLive = () => { +export const cacheMaxAge = () => { const actionLogger = renderLitAsNode(html``); const fetchHandler = () => { ajax .fetchJson(`../assets/pabu.json`, { cacheOptions: { - timeToLive: 1000 * 3, // 3 seconds + maxAge: 1000 * 3, // 3 seconds }, }) .then(result => { diff --git a/docs/docs/tools/ajax/overview.md b/docs/docs/tools/ajax/overview.md index dafaae40d..64b93ac28 100644 --- a/docs/docs/tools/ajax/overview.md +++ b/docs/docs/tools/ajax/overview.md @@ -1,10 +1,7 @@ # Tools >> Ajax >> Overview ||10 ```js script -import { html } from '@mdjs/mdjs-preview'; -import { renderLitAsNode } from '@lion/helpers'; import { ajax, createCacheInterceptors } from '@lion/ajax'; -import '@lion/helpers/define'; const getCacheIdentifier = () => { let userId = localStorage.getItem('lion-ajax-cache-demo-user-id'); @@ -15,9 +12,11 @@ const getCacheIdentifier = () => { return userId; }; +const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds + const cacheOptions = { useCache: true, - timeToLive: 1000 * 60 * 10, // 10 minutes + maxAge: TEN_MINUTES, }; const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( @@ -33,8 +32,8 @@ ajax.addResponseInterceptor(cacheResponseInterceptor); - Allows globally registering request and response interceptors - Throws on 4xx and 5xx status codes -- Prevents network request if a request interceptor returns a response -- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON +- Supports caching, so a request can be prevented from reaching to network, by returning the cached response. +- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and deserializing response payload as JSON, and adding the correct Content-Type and Accept headers. - Adds accept-language header to requests based on application language - Adds XSRF header to request if the cookie is present diff --git a/packages/ajax/src/Ajax.js b/packages/ajax/src/Ajax.js index ea4cf19cc..337337b41 100644 --- a/packages/ajax/src/Ajax.js +++ b/packages/ajax/src/Ajax.js @@ -8,9 +8,14 @@ import { AjaxFetchError } from './AjaxFetchError.js'; import './typedef.js'; /** - * HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which - * intercept request and responses, for example to add authorization headers or logging. A - * request can also be prevented from reaching the network at all by returning the Response directly. + * A small wrapper around `fetch`. +- Allows globally registering request and response interceptors +- Throws on 4xx and 5xx status codes +- Supports caching, so a request can be prevented from reaching to network, by returning the cached response. +- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and + deserializing response payload as JSON, and adding the correct Content-Type and Accept headers. +- Adds accept-language header to requests based on application language +- Adds XSRF header to request if the cookie is present */ export class Ajax { /** @@ -49,18 +54,18 @@ export class Ajax { const { cacheOptions } = this.__config; if (cacheOptions?.useCache) { - const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( + const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors( cacheOptions.getCacheIdentifier, cacheOptions, ); - this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor)); - this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor)); + this.addRequestInterceptor(cacheRequestInterceptor); + this.addResponseInterceptor(cacheResponseInterceptor); } } /** - * Sets the config for the instance - * @param {Partial} config configuration for the AjaxClass instance + * Configures the Ajax instance + * @param {Partial} config configuration for the Ajax instance */ set options(config) { this.__config = config; @@ -95,8 +100,7 @@ export class Ajax { } /** - * Makes a fetch request, calling the registered fetch request and response - * interceptors. + * Fetch by calling the registered request and response interceptors. * * @param {RequestInfo} info * @param {RequestInit & Partial} [init] @@ -126,8 +130,11 @@ export class Ajax { } /** - * Makes a fetch request, calling the registered fetch request and response - * interceptors. Encodes/decodes the request and response body as JSON. + * Fetch by calling the registered request and response + * interceptors. And supports JSON by: + * - Serializing request body as JSON + * - Deserializing response payload as JSON + * - Adding the correct Content-Type and Accept headers * * @param {RequestInfo} info * @param {LionRequestInit} [init] @@ -149,7 +156,7 @@ export class Ajax { lionInit.body = JSON.stringify(lionInit.body); } - // Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit + // typecast LionRequestInit back to RequestInit const jsonInit = /** @type {RequestInit} */ (lionInit); const response = await this.fetch(info, jsonInit); let responseText = await response.text(); diff --git a/packages/ajax/src/Cache.js b/packages/ajax/src/Cache.js new file mode 100644 index 000000000..4f64a7011 --- /dev/null +++ b/packages/ajax/src/Cache.js @@ -0,0 +1,65 @@ +import './typedef.js'; + +export default class Cache { + constructor() { + /** + * @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }} + * @private + */ + this._cachedRequests = {}; + } + + /** + * Store an item in the cache + * @param {string} requestId key by which the request is stored + * @param {Response} response the cached response + */ + set(requestId, response) { + this._cachedRequests[requestId] = { + createdAt: Date.now(), + response, + }; + } + + /** + * Retrieve an item from the cache + * @param {string} requestId key by which the cache is stored + * @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds + * @returns {CacheResponse | undefined} + */ + get(requestId, maxAge = 0) { + const cachedRequest = this._cachedRequests[requestId]; + if (!cachedRequest) { + return; + } + const cachedRequestAge = Date.now() - cachedRequest.createdAt; + if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) { + // eslint-disable-next-line consistent-return + return cachedRequest.response; + } + } + + /** + * Delete the item with the given requestId from the cache + * @param {string } requestId the request id to delete from the cache + */ + delete(requestId) { + delete this._cachedRequests[requestId]; + } + + /** + * Delete all items from the cache that match given regex + * @param {RegExp} regex a regular expression to match cache entries + */ + deleteMatching(regex) { + Object.keys(this._cachedRequests).forEach(requestId => { + if (new RegExp(regex).test(requestId)) { + this.delete(requestId); + } + }); + } + + reset() { + this._cachedRequests = {}; + } +} diff --git a/packages/ajax/src/PendingRequestStore.js b/packages/ajax/src/PendingRequestStore.js new file mode 100644 index 000000000..413519604 --- /dev/null +++ b/packages/ajax/src/PendingRequestStore.js @@ -0,0 +1,62 @@ +import './typedef.js'; + +export default class PendingRequestStore { + constructor() { + /** + * @type {{ [requestId: string]: { promise: Promise, resolve: (value?: any) => void } }} + * @private + */ + this._pendingRequests = {}; + } + + /** + * Creates a promise for a pending request with given key + * @param {string} requestId + */ + set(requestId) { + if (this._pendingRequests[requestId]) { + return; + } + /** @type {(value?: any) => void } */ + let resolve; + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + // @ts-ignore + this._pendingRequests[requestId] = { promise, resolve }; + } + + /** + * Gets the promise for a pending request with given key + * @param {string} requestId + * @returns {Promise | undefined} + */ + get(requestId) { + return this._pendingRequests[requestId]?.promise; + } + + /** + * Resolves the promise of a pending request that matches the given string + * @param { string } requestId the requestId to resolve + */ + resolve(requestId) { + this._pendingRequests[requestId]?.resolve(); + delete this._pendingRequests[requestId]; + } + + /** + * Resolves the promise of pending requests that match the given regex + * @param { RegExp } regex an regular expression to match store entries + */ + resolveMatching(regex) { + Object.keys(this._pendingRequests).forEach(pendingRequestId => { + if (regex.test(pendingRequestId)) { + this.resolve(pendingRequestId); + } + }); + } + + reset() { + this._pendingRequests = {}; + } +} diff --git a/packages/ajax/src/cache.js b/packages/ajax/src/cache.js deleted file mode 100644 index ff046bfcc..000000000 --- a/packages/ajax/src/cache.js +++ /dev/null @@ -1,221 +0,0 @@ -/* eslint-disable consistent-return */ -/* eslint-disable no-param-reassign */ -import './typedef.js'; - -const SECOND = 1000; -const MINUTE = SECOND * 60; -const HOUR = MINUTE * 60; -const DEFAULT_TIME_TO_LIVE = HOUR; - -class Cache { - constructor() { - this.expiration = new Date().getTime() + DEFAULT_TIME_TO_LIVE; - /** - * @type {{[url: string]: {expires: number, response: CacheResponse} }} - * @private - */ - this._cacheObject = {}; - /** - * @type {{ [url: string]: { promise: Promise, resolve: (v?: any) => void } }} - * @private - */ - this._pendingRequests = {}; - } - - /** @param {string} url */ - setPendingRequest(url) { - /** @type {(v: any) => void} */ - let resolve = () => {}; - const promise = new Promise(_resolve => { - resolve = _resolve; - }); - this._pendingRequests[url] = { promise, resolve }; - } - - /** - * @param {string} url - * @returns {Promise | undefined} - */ - getPendingRequest(url) { - if (this._pendingRequests[url]) { - return this._pendingRequests[url].promise; - } - } - - /** @param {string} url */ - resolvePendingRequest(url) { - if (this._pendingRequests[url]) { - this._pendingRequests[url].resolve(); - delete this._pendingRequests[url]; - } - } - - /** - * Store an item in the cache - * @param {string} url key by which the cache is stored - * @param {Response} response the cached response - */ - set(url, response) { - this._validateCache(); - this._cacheObject[url] = { - expires: new Date().getTime(), - response, - }; - } - - /** - * Retrieve an item from the cache - * @param {string} url key by which the cache is stored - * @param {number} timeToLive maximum time to allow cache to live - * @returns {CacheResponse | false} - */ - get(url, timeToLive) { - this._validateCache(); - - const cacheResult = this._cacheObject[url]; - if (!cacheResult) { - return false; - } - const cacheAge = new Date().getTime() - cacheResult.expires; - - if (timeToLive !== null && cacheAge > timeToLive) { - return false; - } - return cacheResult.response; - } - - /** - * Delete all items from the cache that contain the given url - * @param {string} url key by which the cache is stored - */ - delete(url) { - this._validateCache(); - - Object.keys(this._cacheObject).forEach(key => { - if (key.includes(url)) { - delete this._cacheObject[key]; - this.resolvePendingRequest(key); - } - }); - } - - /** - * Delete all items from the cache that match given regex - * @param {RegExp} regex an regular expression to match cache entries - */ - deleteMatched(regex) { - this._validateCache(); - - Object.keys(this._cacheObject).forEach(key => { - const notMatch = !new RegExp(regex).test(key); - if (notMatch) return; - delete this._cacheObject[key]; - this.resolvePendingRequest(key); - }); - } - - /** - * Validate cache on each call to the Cache - * When the expiration date has passed, the _cacheObject will be replaced by an - * empty object - * @protected - */ - _validateCache() { - if (new Date().getTime() > this.expiration) { - this._cacheObject = {}; - return false; - } - return true; - } -} - -let caches = {}; - -/** - * Serialize search parameters into url query string parameters. - * If params === null, returns '' - * @param {Params} params query string parameters object - * @returns {string} of querystring parameters WITHOUT `?` or empty string '' - */ -export const stringifySearchParams = (params = {}) => - typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : ''; - -/** - * Returns the active cache instance for the current session - * If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache - * and proactively clean it - * @param {string} cacheIdentifier usually the refreshToken of the owner of the cache - */ -export const getCache = cacheIdentifier => { - if (caches[cacheIdentifier]?._validateCache()) { - return caches[cacheIdentifier]; - } - // invalidate old caches - caches = {}; - // create new cache - caches[cacheIdentifier] = new Cache(); - return caches[cacheIdentifier]; -}; - -/** - * @param {CacheOptions} options Options to match cache - * @returns {ValidatedCacheOptions} - */ -export const validateCacheOptions = ({ - useCache = false, - methods = ['get'], - timeToLive, - invalidateUrls, - invalidateUrlsRegex, - requestIdentificationFn, -}) => { - // validate 'cache' - if (typeof useCache !== 'boolean') { - throw new Error('Property `useCache` should be `true` or `false`'); - } - - if (methods[0] !== 'get' || methods.length !== 1) { - throw new Error('Functionality to use cache on anything except "get" is not yet supported'); - } - - // validate 'timeToLive', default 1 hour - if (timeToLive === undefined) { - timeToLive = DEFAULT_TIME_TO_LIVE; - } - if (Number.isNaN(parseInt(String(timeToLive), 10))) { - throw new Error('Property `timeToLive` must be of type `number`'); - } - // validate 'invalidateUrls', must be an `Array` or `falsy` - if (invalidateUrls) { - if (!Array.isArray(invalidateUrls)) { - throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`'); - } - } - // validate 'invalidateUrlsRegex', must be an regex expression or `falsy` - if (invalidateUrlsRegex) { - if (!(invalidateUrlsRegex instanceof RegExp)) { - throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`'); - } - } - // validate 'requestIdentificationFn', default is url + searchParams - if (requestIdentificationFn) { - if (typeof requestIdentificationFn !== 'function') { - throw new Error('Property `requestIdentificationFn` must be of type `function`'); - } - } else { - // eslint-disable-next-line no-shadow - requestIdentificationFn = /** @param {any} data */ ({ url, params }, stringifySearchParams) => { - const serializedParams = stringifySearchParams(params); - return serializedParams ? `${url}?${serializedParams}` : url; - }; - } - - return { - useCache, - methods, - timeToLive, - invalidateUrls, - invalidateUrlsRegex, - requestIdentificationFn, - }; -}; diff --git a/packages/ajax/src/cacheManager.js b/packages/ajax/src/cacheManager.js new file mode 100644 index 000000000..67ad5c3b0 --- /dev/null +++ b/packages/ajax/src/cacheManager.js @@ -0,0 +1,157 @@ +import './typedef.js'; +import Cache from './Cache.js'; +import PendingRequestStore from './PendingRequestStore.js'; + +/** + * The id for the cache session + * @type {string | undefined} + */ +let cacheSessionId; + +/** + * The ajax cache + * @type {Cache} + */ +export const ajaxCache = new Cache(); + +/** + * The pending request store + * @type {PendingRequestStore} + */ +export const pendingRequestStore = new PendingRequestStore(); + +/** + * Checks whether the given cacheSessionId matches the currently active id. + * + * @param {string|undefined} cacheId The cache id to check + */ +export const isCurrentSessionId = cacheId => cacheId === cacheSessionId; + +/** + * Resets the cache session when the cacheId changes. + * + * There can be only 1 active session at all times. + * @param {string} cacheId The cache id that is tied to the current session + */ +export const resetCacheSession = cacheId => { + if (!cacheId) { + throw new Error('Invalid cache identifier'); + } + if (!isCurrentSessionId(cacheId)) { + cacheSessionId = cacheId; + ajaxCache.reset(); + pendingRequestStore.reset(); + } +}; + +/** + * Stringify URL search params + * @param {Params} params query string parameters object + * @returns {string} of querystring parameters WITHOUT `?` or empty string '' + */ +const stringifySearchParams = (params = {}) => + typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : ''; + +/** + * Returns request key string, which uniquely identifies a Request + * @param {Partial} request Request object + * @param {function} serializeSearchParams Function to serialize URL search params + * @returns {string} requestId to uniquely identify a request + */ +const DEFAULT_GET_REQUEST_ID = ( + { url = '', params }, + serializeSearchParams = stringifySearchParams, +) => { + const serializedParams = serializeSearchParams(params); + return serializedParams ? `${url}?${serializedParams}` : url; +}; + +/** + * Defaults to 1 hour + */ +const DEFAULT_MAX_AGE = 1000 * 60 * 60; + +/** + * @param {CacheOptions} options Cache options + * @returns {ValidatedCacheOptions} + */ +export const extendCacheOptions = ({ + useCache = false, + methods = ['get'], + maxAge = DEFAULT_MAX_AGE, + requestIdFunction = DEFAULT_GET_REQUEST_ID, + invalidateUrls, + invalidateUrlsRegex, +}) => ({ + useCache, + methods, + maxAge, + requestIdFunction, + invalidateUrls, + invalidateUrlsRegex, +}); + +/** + * @param {CacheOptions} options Cache options + */ +export const validateCacheOptions = ({ + useCache, + methods, + maxAge, + requestIdFunction, + invalidateUrls, + invalidateUrlsRegex, +} = {}) => { + if (useCache !== undefined && typeof useCache !== 'boolean') { + throw new Error('Property `useCache` must be a `boolean`'); + } + if (methods !== undefined && JSON.stringify(methods) !== JSON.stringify(['get'])) { + throw new Error('Cache can only be utilized with `GET` method'); + } + if (maxAge !== undefined && !Number.isFinite(maxAge)) { + throw new Error('Property `maxAge` must be a finite `number`'); + } + if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) { + throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`'); + } + if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) { + throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); + } + if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') { + throw new Error('Property `requestIdFunction` must be a `function`'); + } +}; + +/** + * Invalidates matching requestIds in the cache and pendingRequestStore + * + * There are two kinds of invalidate rules: + * invalidateUrls (array of URL like strings) + * invalidateUrlsRegex (RegExp) + * If a non-GET method is fired, by default it only invalidates its own endpoint. + * Invalidating /api/users cache by doing a PATCH, will not invalidate /api/accounts cache. + * However, in the case of users and accounts, they may be very interconnected, + * so perhaps you do want to invalidate /api/accounts when invalidating /api/users. + * If it's NOT one of the config.methods, invalidate caches + * + * @param requestId { string } + * @param cacheOptions { CacheOptions } + */ +export const invalidateMatchingCache = (requestId, { invalidateUrls, invalidateUrlsRegex }) => { + // invalidate this request + ajaxCache.delete(requestId); + pendingRequestStore.resolve(requestId); + + // also invalidate caches matching to invalidateUrls + if (Array.isArray(invalidateUrls)) { + invalidateUrls.forEach(url => { + ajaxCache.delete(url); + pendingRequestStore.resolve(url); + }); + } + // also invalidate caches matching to invalidateUrlsRegex + if (invalidateUrlsRegex) { + ajaxCache.deleteMatching(invalidateUrlsRegex); + pendingRequestStore.resolveMatching(invalidateUrlsRegex); + } +}; diff --git a/packages/ajax/src/interceptors/cacheInterceptors.js b/packages/ajax/src/interceptors/cacheInterceptors.js index fa85904ab..f00c1803b 100644 --- a/packages/ajax/src/interceptors/cacheInterceptors.js +++ b/packages/ajax/src/interceptors/cacheInterceptors.js @@ -1,137 +1,112 @@ /* eslint-disable no-param-reassign */ import '../typedef.js'; -import { validateCacheOptions, stringifySearchParams, getCache } from '../cache.js'; +import { + ajaxCache, + resetCacheSession, + extendCacheOptions, + validateCacheOptions, + invalidateMatchingCache, + pendingRequestStore, + isCurrentSessionId, +} from '../cacheManager.js'; /** * Request interceptor to return relevant cached requests - * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed + * @param {function(): string} getCacheId used to invalidate cache if identifier is changed * @param {CacheOptions} globalCacheOptions * @returns {RequestInterceptor} */ -const createCacheRequestInterceptor = (getCacheIdentifier, globalCacheOptions) => { - const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions); +const createCacheRequestInterceptor = + (getCacheId, globalCacheOptions) => /** @param {CacheRequest} request */ async request => { + validateCacheOptions(request.cacheOptions); + const cacheSessionId = getCacheId(); + resetCacheSession(cacheSessionId); // cacheSessionId is used to bind the cache to the current session - return /** @param {CacheRequest} cacheRequest */ async cacheRequest => { - const cacheOptions = validateCacheOptions({ - ...validatedInitialCacheOptions, - ...cacheRequest.cacheOptions, + const cacheOptions = extendCacheOptions({ + ...globalCacheOptions, + ...request.cacheOptions, }); - cacheRequest.cacheOptions = cacheOptions; + // store cacheOptions and cacheSessionId in the request, to use it in the response interceptor. + request.cacheOptions = cacheOptions; + request.cacheSessionId = cacheSessionId; - // don't use cache if 'useCache' === false if (!cacheOptions.useCache) { - return cacheRequest; + return request; } - const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, stringifySearchParams); - // cacheIdentifier is used to bind the cache to the current session - const currentCache = getCache(getCacheIdentifier()); - const { method } = cacheRequest; + const requestId = cacheOptions.requestIdFunction(request); + const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase()); - // don't use cache if the request method is not part of the configs methods - if (!cacheOptions.methods.includes(method.toLowerCase())) { - // If it's NOT one of the config.methods, invalidate caches - currentCache.delete(cacheId); - // also invalidate caches matching to cacheOptions - if (cacheOptions.invalidateUrls) { - cacheOptions.invalidateUrls.forEach( - /** @type {string} */ invalidateUrl => { - currentCache.delete(invalidateUrl); - }, - ); - } - // also invalidate caches matching to invalidateUrlsRegex - if (cacheOptions.invalidateUrlsRegex) { - currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex); - } - - return cacheRequest; + if (!isMethodSupported) { + invalidateMatchingCache(requestId, cacheOptions); + return request; } - const pendingRequest = currentCache.getPendingRequest(cacheId); + const pendingRequest = pendingRequestStore.get(requestId); if (pendingRequest) { // there is another concurrent request, wait for it to finish await pendingRequest; } - const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive); - if (cacheResponse) { - cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false }; - const response = /** @type {CacheResponse} */ cacheResponse.clone(); - response.request = cacheRequest; + const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); + if (cachedResponse) { + // Return the response from cache + request.cacheOptions = request.cacheOptions ?? { useCache: false }; + /** @type {CacheResponse} */ + const response = cachedResponse.clone(); + response.request = request; response.fromCache = true; return response; } - // we do want to use caching for this requesting, but it's not already cached - // mark this as a pending request, so that concurrent requests can reuse it from the cache - currentCache.setPendingRequest(cacheId); - - return cacheRequest; + // Mark this as a pending request, so that concurrent requests can use the response from this request + pendingRequestStore.set(requestId); + return request; }; -}; /** * Response interceptor to cache relevant requests - * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed * @param {CacheOptions} globalCacheOptions * @returns {ResponseInterceptor} */ -const createCacheResponseInterceptor = (getCacheIdentifier, globalCacheOptions) => { - const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions); - - /** - * Axios response https://github.com/axios/axios#response-schema - */ - return /** @param {CacheResponse} cacheResponse */ async cacheResponse => { - if (!getCacheIdentifier()) { - throw new Error(`getCacheIdentifier returns falsy`); +const createCacheResponseInterceptor = + globalCacheOptions => /** @param {CacheResponse} response */ async response => { + if (!response.request) { + throw new Error('Missing request in response'); } - if (!cacheResponse.request) { - throw new Error('Missing request in response.'); - } - - const cacheOptions = validateCacheOptions({ - ...validatedInitialCacheOptions, - ...cacheResponse.request?.cacheOptions, + const cacheOptions = extendCacheOptions({ + ...globalCacheOptions, + ...response.request.cacheOptions, }); - // string that identifies cache entry - const cacheId = cacheOptions.requestIdentificationFn( - cacheResponse.request, - stringifySearchParams, - ); - const currentCache = getCache(getCacheIdentifier()); - const isAlreadyFromCache = !!cacheResponse.fromCache; - // caching all responses with not default `timeToLive` - const isCacheActive = cacheOptions.timeToLive > 0; - const isMethodSupported = cacheOptions.methods.includes( - cacheResponse.request.method.toLowerCase(), - ); - // if the request is one of the options.methods; store response in cache - if (!isAlreadyFromCache && isCacheActive && isMethodSupported) { - // store the response data in the cache and mark request as resolved - currentCache.set(cacheId, cacheResponse.clone()); - } + const requestId = cacheOptions.requestIdFunction(response.request); + const isAlreadyFromCache = !!response.fromCache; + const isCacheActive = cacheOptions.useCache; + const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase()); - currentCache.resolvePendingRequest(cacheId); - return cacheResponse; + if (!isAlreadyFromCache && isCacheActive && isMethodSupported) { + if (isCurrentSessionId(response.request.cacheSessionId)) { + // Cache the response + ajaxCache.set(requestId, response.clone()); + } + + // Mark the pending request as resolved + pendingRequestStore.resolve(requestId); + } + return response; }; -}; /** * Response interceptor to cache relevant requests - * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed + * @param {function(): string} getCacheId used to invalidate cache if identifier is changed * @param {CacheOptions} globalCacheOptions - * @returns [{RequestInterceptor}, {ResponseInterceptor}] + * @returns {{cacheRequestInterceptor: RequestInterceptor, cacheResponseInterceptor: ResponseInterceptor}} */ -export const createCacheInterceptors = (getCacheIdentifier, globalCacheOptions) => { - const requestInterceptor = createCacheRequestInterceptor(getCacheIdentifier, globalCacheOptions); - const responseInterceptor = createCacheResponseInterceptor( - getCacheIdentifier, - globalCacheOptions, - ); - return [requestInterceptor, responseInterceptor]; +export const createCacheInterceptors = (getCacheId, globalCacheOptions) => { + validateCacheOptions(globalCacheOptions); + const cacheRequestInterceptor = createCacheRequestInterceptor(getCacheId, globalCacheOptions); + const cacheResponseInterceptor = createCacheResponseInterceptor(globalCacheOptions); + return { cacheRequestInterceptor, cacheResponseInterceptor }; }; diff --git a/packages/ajax/src/typedef.js b/packages/ajax/src/typedef.js index 201b0de78..62c31af80 100644 --- a/packages/ajax/src/typedef.js +++ b/packages/ajax/src/typedef.js @@ -5,7 +5,7 @@ * @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor * @typedef {import('../types/types').CacheConfig} CacheConfig * @typedef {import('../types/types').Params} Params - * @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn + * @typedef {import('../types/types').RequestIdFunction} RequestIdFunction * @typedef {import('../types/types').CacheOptions} CacheOptions * @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions * @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension diff --git a/packages/ajax/test/Ajax.test.js b/packages/ajax/test/Ajax.test.js index f7ae7dc9b..786931b9a 100644 --- a/packages/ajax/test/Ajax.test.js +++ b/packages/ajax/test/Ajax.test.js @@ -26,7 +26,7 @@ describe('Ajax', () => { jsonPrefix: ")]}',", cacheOptions: { useCache: true, - timeToLive: 1000 * 60 * 5, // 5 minutes + maxAge: 1000 * 60 * 5, // 5 minutes getCacheIdentifier, }, }; @@ -37,7 +37,7 @@ describe('Ajax', () => { jsonPrefix: ")]}',", cacheOptions: { useCache: true, - timeToLive: 300000, + maxAge: 300000, getCacheIdentifier, }, }; @@ -53,7 +53,7 @@ describe('Ajax', () => { const config = { cacheOptions: { useCache: true, - timeToLive: 1000 * 60 * 5, // 5 minutes + maxAge: 1000 * 60 * 5, // 5 minutes }, }; // When @@ -288,7 +288,7 @@ describe('Ajax', () => { const customAjax = new Ajax({ cacheOptions: { useCache: true, - timeToLive: 100, + maxAge: 100, getCacheIdentifier, }, }); diff --git a/packages/ajax/test/Cache.test.js b/packages/ajax/test/Cache.test.js new file mode 100644 index 000000000..7e27fb77c --- /dev/null +++ b/packages/ajax/test/Cache.test.js @@ -0,0 +1,196 @@ +// @ts-nocheck +import { expect } from '@open-wc/testing'; +import Cache from '../src/Cache.js'; + +const A_MINUTE_IN_MS = 60 * 1000; +const TWO_MINUTES_IN_MS = 2 * A_MINUTE_IN_MS; +const TEN_MINUTES_IN_MS = 10 * A_MINUTE_IN_MS; + +describe('Cache', () => { + describe('public interface', () => { + const cache = new Cache(); + + it('Cache has `set` method', () => { + expect(cache.set).to.exist; + }); + + it('Cache has `get` method', () => { + expect(cache.get).to.exist; + }); + + it('Cache has `delete` method', () => { + expect(cache.delete).to.exist; + }); + + it('Cache has `reset` method', () => { + expect(cache.reset).to.exist; + }); + }); + + describe('cache.get', () => { + // Mock cache data + const cache = new Cache(); + + cache._cachedRequests = { + requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' }, + requestId2: { createdAt: Date.now(), response: 'cached data 2' }, + }; + + it('returns undefined if no cached request found for requestId', () => { + // Given + const maxAge = TEN_MINUTES_IN_MS; + const expected = undefined; + // When + const result = cache.get('nonCachedRequestId', maxAge); + // Then + expect(result).to.equal(expected); + }); + + it('returns undefined if maxAge is not a number', () => { + // Given + const maxAge = 'some string'; + const expected = undefined; + // When + const result = cache.get('requestId1', maxAge); + // Then + expect(result).to.equal(expected); + }); + + it('returns undefined if maxAge is not finite', () => { + // Given + const maxAge = 1 / 0; + const expected = undefined; + // When + const result = cache.get('requestId1', maxAge); + // Then + expect(result).to.equal(expected); + }); + + it('returns undefined if maxAge is negative', () => { + // Given + const maxAge = -10; + const expected = undefined; + // When + const result = cache.get('requestId1', maxAge); + // Then + expect(result).to.equal(expected); + }); + + it('returns undefined if cached request age is not less than maxAge', () => { + // Given + const maxAge = A_MINUTE_IN_MS; + const expected = undefined; + // When + const result = cache.get('requestId1', maxAge); + // Then + expect(result).to.equal(expected); + }); + + it('gets the cached request by requestId if cached request age is less than maxAge', () => { + // Given + const maxAge = TEN_MINUTES_IN_MS; + const expected = cache._cachedRequests?.requestId1?.response; + // When + const result = cache.get('requestId1', maxAge); + // Then + expect(result).to.deep.equal(expected); + }); + }); + + describe('cache.set', () => { + it('stores the `response` for the given `requestId`', () => { + // Given + const cache = new Cache(); + const maxAge = TEN_MINUTES_IN_MS; + const response1 = 'response of request1'; + const response2 = 'response of request2'; + // When + cache.set('requestId1', response1); + cache.set('requestId2', response2); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(response1); + expect(cache.get('requestId2', maxAge)).to.equal(response2); + }); + + it('updates the `response` for the given `requestId`, if already cached', () => { + // Given + const cache = new Cache(); + const maxAge = TEN_MINUTES_IN_MS; + const response = 'response of request1'; + const updatedResponse = 'updated response of request1'; + // When + cache.set('requestId1', response); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(response); + // When + cache.set('requestId1', updatedResponse); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(updatedResponse); + }); + }); + + describe('cache.delete', () => { + it('deletes cache by `requestId`', () => { + // Given + const cache = new Cache(); + const maxAge = TEN_MINUTES_IN_MS; + const response1 = 'response of request1'; + const response2 = 'response of request2'; + // When + cache.set('requestId1', response1); + cache.set('requestId2', response2); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(response1); + expect(cache.get('requestId2', maxAge)).to.equal(response2); + // When + cache.delete('requestId1'); + // Then + expect(cache.get('requestId1', maxAge)).to.be.undefined; + expect(cache.get('requestId2', maxAge)).to.equal(response2); + }); + + it('deletes cache by regex', () => { + // Given + const cache = new Cache(); + const maxAge = TEN_MINUTES_IN_MS; + const response1 = 'response of request1'; + const response2 = 'response of request2'; + const response3 = 'response of request3'; + // When + cache.set('requestId1', response1); + cache.set('requestId2', response2); + cache.set('anotherRequestId', response3); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(response1); + expect(cache.get('requestId2', maxAge)).to.equal(response2); + expect(cache.get('anotherRequestId', maxAge)).to.equal(response3); + // When + cache.deleteMatching(/^requestId/); + // Then + expect(cache.get('requestId1', maxAge)).to.be.undefined; + expect(cache.get('requestId2', maxAge)).to.be.undefined; + expect(cache.get('anotherRequestId', maxAge)).to.equal(response3); + }); + }); + + describe('cache.reset', () => { + it('resets the cache', () => { + // Given + const cache = new Cache(); + const maxAge = TEN_MINUTES_IN_MS; + const response1 = 'response of request1'; + const response2 = 'response of request2'; + // When + cache.set('requestId1', response1); + cache.set('requestId2', response2); + // Then + expect(cache.get('requestId1', maxAge)).to.equal(response1); + expect(cache.get('requestId2', maxAge)).to.equal(response2); + // When + cache.reset(); + // Then + expect(cache.get('requestId1', maxAge)).to.be.undefined; + expect(cache.get('requestId2', maxAge)).to.be.undefined; + }); + }); +}); diff --git a/packages/ajax/test/PendingRequestStore.test.js b/packages/ajax/test/PendingRequestStore.test.js new file mode 100644 index 000000000..19d906b71 --- /dev/null +++ b/packages/ajax/test/PendingRequestStore.test.js @@ -0,0 +1,159 @@ +// @ts-nocheck +import { expect } from '@open-wc/testing'; +import PendingRequestStore from '../src/PendingRequestStore.js'; + +describe('PendingRequestStore', () => { + let pendingRequestStore; + + beforeEach(() => { + pendingRequestStore = new PendingRequestStore(); + }); + + describe('public interface', () => { + it('PendingRequestStore has `set` method', () => { + expect(pendingRequestStore.set).to.exist; + }); + + it('PendingRequestStore has `get` method', () => { + expect(pendingRequestStore.get).to.exist; + }); + + it('PendingRequestStore has `resolve` method', () => { + expect(pendingRequestStore.resolve).to.exist; + }); + + it('PendingRequestStore has `reset` method', () => { + expect(pendingRequestStore.reset).to.exist; + }); + }); + + describe('getting and setting', () => { + it('will return undefined for an unknown key', () => { + expect(pendingRequestStore.get('unknown-key')).to.be.undefined; + }); + + it('will return a promise for a key that has been added earlier', () => { + // Given + pendingRequestStore.set('a-key'); + + // Then + expect(pendingRequestStore.get('a-key')).to.be.a('Promise'); + }); + + it('will not replace an already known entry', () => { + // Given + pendingRequestStore.set('the-original-key'); + const theOriginalPromise = pendingRequestStore.get('the-original-key'); + + // When + pendingRequestStore.set('the-original-key'); + + // Then + expect(pendingRequestStore.get('the-original-key')).to.equal(theOriginalPromise); + }); + + it('will return the same promise when retrieved twice', () => { + // Given + pendingRequestStore.set('a-key'); + + // When + const a1 = pendingRequestStore.get('a-key'); + const a2 = pendingRequestStore.get('a-key'); + + // Then + expect(a1).to.equal(a2); + }); + + it('will return undefined when the store is reset', () => { + // Given + pendingRequestStore.set('a-key'); + + // When + pendingRequestStore.reset(); + + // Then + expect(pendingRequestStore.get('a-key')).to.be.undefined; + }); + }); + + describe('resolving', () => { + it('will resolve a named promise and delete it', async () => { + // Given + pendingRequestStore.set('do-groceries'); + const backFromTheStore = pendingRequestStore + .get('do-groceries') + .catch(() => expect.fail('Promise was rejected before it could be resolved')); + + // When + pendingRequestStore.resolve('do-groceries'); + await backFromTheStore; + + // Then + expect(pendingRequestStore.get('do-groceries')).to.be.undefined; + }); + + it('will use the same promise when retrieving or resolving the same key', async () => { + // Given + const fridge = []; + + pendingRequestStore.set('do-groceries'); + pendingRequestStore.get('do-groceries').then(() => fridge.push('milk')); + pendingRequestStore.get('do-groceries').then(() => fridge.push('eggs')); + + const backFromTheStore = pendingRequestStore.get('do-groceries'); + + // When + pendingRequestStore.resolve('do-groceries'); + await backFromTheStore; + + // Then + expect(fridge).to.contain('milk'); + expect(fridge).to.contain('eggs'); + + expect(pendingRequestStore.get('do-groceries')).to.be.undefined; + }); + }); + + describe('resolving multiple requestIds by regular expression', () => { + it('will resolve multiple promises matching a regular expression and delete them', async () => { + // Given + let canIPlayNow = false; + + pendingRequestStore.set('do-dishes'); + pendingRequestStore.set('do-groceries'); + + const choresAllDone = Promise.all([ + pendingRequestStore.get('do-dishes'), + pendingRequestStore.get('do-groceries'), + ]); + + // When + pendingRequestStore.resolveMatching(/^do-/); + await choresAllDone.then(() => { + canIPlayNow = true; + }); + + // Then + expect(canIPlayNow).to.be.ok; + + expect(pendingRequestStore.get('do-groceries')).to.be.undefined; + expect(pendingRequestStore.get('do-dishes')).to.be.undefined; + }); + }); + + it('will leave unmatched requests alone when resolving', () => { + // Given + pendingRequestStore.set('do-dishes'); + pendingRequestStore.set('do-groceries'); + pendingRequestStore.set('ponder-meaning-of-life'); + + // When + pendingRequestStore.resolveMatching(/^do-/); + + // Then + expect(pendingRequestStore.get('do-groceries')).to.be.undefined; + expect(pendingRequestStore.get('do-dishes')).to.be.undefined; + + expect(pendingRequestStore.get('ponder-meaning-of-life')).not.to.be.undefined; + }); +}); diff --git a/packages/ajax/test/cacheManager.test.js b/packages/ajax/test/cacheManager.test.js new file mode 100644 index 000000000..904119849 --- /dev/null +++ b/packages/ajax/test/cacheManager.test.js @@ -0,0 +1,313 @@ +// @ts-nocheck +import { expect } from '@open-wc/testing'; +import * as sinon from 'sinon'; +import { + ajaxCache, + pendingRequestStore, + resetCacheSession, + extendCacheOptions, + validateCacheOptions, + invalidateMatchingCache, + isCurrentSessionId, +} from '../src/cacheManager.js'; +import Cache from '../src/Cache.js'; +import PendingRequestStore from '../src/PendingRequestStore.js'; + +describe('cacheManager', () => { + describe('ajaxCache', () => { + it('is an instance of the Cache class', () => { + expect(ajaxCache).to.be.instanceOf(Cache); + }); + }); + + describe('pendingRequestStore', () => { + it('is an instance of the PendingRequestStore class', () => { + expect(pendingRequestStore).to.be.instanceOf(PendingRequestStore); + }); + }); + + describe('resetCacheSession', () => { + let ajaxCacheSpy; + let pendingRequestStoreSpy; + + beforeEach(() => { + ajaxCacheSpy = sinon.spy(ajaxCache, 'reset'); + pendingRequestStoreSpy = sinon.spy(pendingRequestStore, 'reset'); + }); + + afterEach(() => { + ajaxCacheSpy.restore(); + pendingRequestStoreSpy.restore(); + }); + + it('throws an Error when no cacheId is passed', () => { + try { + resetCacheSession(); + } catch (e) { + expect(e).to.be.an.instanceOf(Error); + } + }); + + it('assigns the passed cacheId to the cacheSessionId', () => { + // Arrange + const cacheId = 'a-new-cache-id'; + // Act + resetCacheSession(cacheId); + // Assert + expect(ajaxCacheSpy.calledOnce).to.be.true; + expect(pendingRequestStoreSpy.calledOnce).to.be.true; + }); + }); + + describe('extendCacheOptions', () => { + // Arrange + const DEFAULT_MAX_AGE = 1000 * 60 * 60; + const invalidateUrls = ['https://f00.bar/', 'https://share.ware/']; + const invalidateUrlsRegex = /f00/; + + it('returns object with default values', () => { + // Act + const { + useCache, + methods, + maxAge, + requestIdFunction, + invalidateUrls: invalidateUrlsResult, + invalidateUrlsRegex: invalidateUrlsRegexResult, + } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); + // Assert + expect(useCache).to.be.false; + expect(methods).to.eql(['get']); + expect(maxAge).to.equal(DEFAULT_MAX_AGE); + expect(typeof requestIdFunction).to.eql('function'); + expect(invalidateUrlsResult).to.equal(invalidateUrls); + expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); + }); + + it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => { + // Arrange + const { requestIdFunction } = extendCacheOptions({ + invalidateUrls, + invalidateUrlsRegex, + }); + // Act + expect(requestIdFunction).to.throw(TypeError); + }); + + it('the DEFAULT_GET_REQUEST_ID function returns a url when URLSearchParams cannot be serialized', () => { + // Arrange + const { requestIdFunction } = extendCacheOptions({ + invalidateUrls, + invalidateUrlsRegex, + }); + // Act + const formattedUrl = requestIdFunction({ + url: 'http://f00.bar/', + params: {}, + }); + // Assert + expect(formattedUrl).to.equal('http://f00.bar/'); + }); + + it('the DEFAULT_GET_REQUEST_ID function returns a correctly formatted url with URLSearchParams', () => { + // Arrange + const { requestIdFunction } = extendCacheOptions({ + invalidateUrls, + invalidateUrlsRegex, + }); + // Act + const formattedUrl = requestIdFunction({ + url: 'http://f00.bar/', + params: { f00: 'bar', bar: 'f00' }, + }); + // Assert + expect(formattedUrl).to.equal('http://f00.bar/?f00=bar&bar=f00'); + }); + }); + + describe('validateCacheOptions', () => { + it('does not accept null as argument', () => { + expect(() => validateCacheOptions(null)).to.throw(TypeError); + }); + it('accepts an empty object', () => { + expect(() => validateCacheOptions({})).not.to.throw( + 'Property `useCache` must be a `boolean`', + ); + }); + describe('the useCache property', () => { + it('accepts a boolean', () => { + expect(() => validateCacheOptions({ useCache: false })).not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + // @ts-ignore + expect(() => validateCacheOptions({ useCache: '' })).to.throw( + 'Property `useCache` must be a `boolean`', + ); + }); + }); + describe('the methods property', () => { + it('accepts an array with the value `get`', () => { + expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ methods: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + expect(() => validateCacheOptions({ methods: [] })).to.throw( + 'Cache can only be utilized with `GET` method', + ); + expect(() => validateCacheOptions({ methods: ['post'] })).to.throw( + 'Cache can only be utilized with `GET` method', + ); + expect(() => validateCacheOptions({ methods: ['get', 'post'] })).to.throw( + 'Cache can only be utilized with `GET` method', + ); + }); + }); + describe('the maxAge property', () => { + it('accepts a finite number', () => { + expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + // @ts-ignore + expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw( + 'Property `maxAge` must be a finite `number`', + ); + expect(() => validateCacheOptions({ maxAge: Infinity })).to.throw( + 'Property `maxAge` must be a finite `number`', + ); + }); + }); + describe('the invalidateUrls property', () => { + it('accepts an array', () => { + // @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions + expect(() => + validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }), + ).not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + // @ts-ignore + expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw( + 'Property `invalidateUrls` must be an `Array` or `falsy`', + ); + }); + }); + describe('the invalidateUrlsRegex property', () => { + it('accepts a regular expression', () => { + expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ })) + .not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + // @ts-ignore + expect(() => + validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }), + ).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); + }); + }); + describe('the requestIdFunction property', () => { + it('accepts a function', () => { + // @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions + expect(() => + validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }), + ).not.to.throw; + }); + it('accepts undefined', () => { + expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw; + }); + it('does not accept anything else', () => { + // @ts-ignore + expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw( + 'Property `requestIdFunction` must be a `function`', + ); + }); + }); + }); + + describe('invalidateMatchingCache', () => { + beforeEach(() => { + sinon.spy(ajaxCache, 'delete'); + sinon.spy(ajaxCache, 'deleteMatching'); + sinon.spy(pendingRequestStore, 'resolve'); + sinon.spy(pendingRequestStore, 'resolveMatching'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('calls delete on the ajaxCache and calls resolve on the pendingRequestStore', () => { + // Arrange + const requestId = 'request-id'; + // Act + invalidateMatchingCache(requestId, {}); + // Assert + expect(ajaxCache.delete).to.have.been.calledOnce; + expect(pendingRequestStore.resolve.calledOnce).to.be.true; + + expect(ajaxCache.delete.calledWith(requestId)).to.be.true; + expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true; + }); + + it('calls invalidateMatching for all URL items in the invalidateUrls argument', () => { + // Arrange + const requestId = 'request-id'; + const invalidateUrls = ['https://f00.bar/']; + // Act + invalidateMatchingCache(requestId, { invalidateUrls }); + // Assert + expect(ajaxCache.delete.calledTwice).to.be.true; + expect(pendingRequestStore.resolve.calledTwice).to.be.true; + + expect(ajaxCache.delete.calledWith(requestId)).to.be.true; + expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true; + + expect(ajaxCache.delete.calledWith('https://f00.bar/')).to.be.true; + expect(pendingRequestStore.resolve.calledWith('https://f00.bar/')).to.be.true; + }); + + it('calls invalidateMatching when the invalidateUrlsRegex argument is passed', () => { + // Arrange + const requestId = 'request-id'; + const invalidateUrlsRegex = 'f00'; + // Act + invalidateMatchingCache(requestId, { invalidateUrlsRegex }); + // Assert + expect(ajaxCache.delete.calledOnce).to.be.true; + expect(ajaxCache.deleteMatching.calledOnce).to.be.true; + expect(pendingRequestStore.resolve.calledOnce).to.be.true; + expect(pendingRequestStore.resolveMatching.calledOnce).to.be.true; + + expect(ajaxCache.delete.calledWith(requestId)).to.be.true; + expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true; + + expect(ajaxCache.deleteMatching.calledWith('f00')).to.be.true; + expect(pendingRequestStore.resolveMatching.calledWith('f00')).to.be.true; + }); + }); + + describe('isCurrentSessionId', () => { + it('returns true for the current session id', () => { + resetCacheSession('the-id'); + + expect(isCurrentSessionId('the-id')).to.equal(true); + }); + + it('returns true for the current session id', () => { + resetCacheSession('the-id'); + + expect(isCurrentSessionId('a-different-id')).to.equal(false); + }); + }); +}); diff --git a/packages/ajax/test/interceptors.test.js b/packages/ajax/test/interceptors.test.js deleted file mode 100644 index 778a34fd3..000000000 --- a/packages/ajax/test/interceptors.test.js +++ /dev/null @@ -1,586 +0,0 @@ -import { aTimeout, expect } from '@open-wc/testing'; -import { spy, stub, useFakeTimers } from 'sinon'; -import '../src/typedef.js'; -import { acceptLanguageRequestInterceptor } from '../src/interceptors/acceptLanguageHeader.js'; -import { createXsrfRequestInterceptor, getCookie } from '../src/interceptors/xsrfHeader.js'; -import { createCacheInterceptors } from '../src/interceptors/cacheInterceptors.js'; -import { Ajax } from '../index.js'; - -const ajax = new Ajax(); - -describe('interceptors', () => { - describe('getCookie()', () => { - it('returns the cookie value', () => { - expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar'); - }); - - it('returns the cookie value when there are multiple cookies', () => { - expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar'); - }); - - it('returns null when the cookie cannot be found', () => { - expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null); - }); - - it('decodes the cookie vaue', () => { - expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal( - '/foo/ bar "', - ); - }); - }); - - describe('acceptLanguageRequestInterceptor()', () => { - it('adds the locale as accept-language header', () => { - const request = new Request('/foo/'); - acceptLanguageRequestInterceptor(request); - expect(request.headers.get('accept-language')).to.equal('en'); - }); - - it('does not change an existing accept-language header', () => { - const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } }); - acceptLanguageRequestInterceptor(request); - expect(request.headers.get('accept-language')).to.equal('my-accept'); - }); - }); - - describe('createXsrfRequestInterceptor()', () => { - it('adds the xsrf token header to the request', () => { - const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { - cookie: 'XSRF-TOKEN=foo', - }); - const request = new Request('/foo/'); - interceptor(request); - expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo'); - }); - - it('does not set anything if the cookie is not there', () => { - const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { - cookie: 'XXSRF-TOKEN=foo', - }); - const request = new Request('/foo/'); - interceptor(request); - expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null); - }); - }); - - describe('cache interceptors', () => { - /** @type {number | undefined} */ - let cacheId; - /** @type {import('sinon').SinonStub} */ - let fetchStub; - /** @type {() => string} */ - let getCacheIdentifier; - - const newCacheId = () => { - if (!cacheId) { - cacheId = 1; - } else { - cacheId += 1; - } - return cacheId; - }; - - /** - * @param {ajax} ajaxInstance - * @param {CacheOptions} options - */ - const addCacheInterceptors = (ajaxInstance, options) => { - const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( - getCacheIdentifier, - options, - ); - - const requestInterceptorIndex = - ajaxInstance._requestInterceptors.push( - /** @type {RequestInterceptor} */ (cacheRequestInterceptor), - ) - 1; - - const responseInterceptorIndex = - ajaxInstance._responseInterceptors.push( - /** @type {ResponseInterceptor} */ (cacheResponseInterceptor), - ) - 1; - - return { - requestInterceptorIndex, - responseInterceptorIndex, - }; - }; - - /** - * @param {ajax} ajaxInstance - * @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes - */ - const removeCacheInterceptors = ( - ajaxInstance, - { requestInterceptorIndex, responseInterceptorIndex }, - ) => { - ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1); - ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1); - }; - - beforeEach(() => { - getCacheIdentifier = () => String(cacheId); - fetchStub = stub(window, 'fetch'); - fetchStub.returns(Promise.resolve(new Response('mock response'))); - }); - - afterEach(() => { - fetchStub.restore(); - }); - - describe('Original ajax instance', () => { - it('allows direct ajax calls without cache interceptors configured', async () => { - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(2); - }); - }); - - describe('Cache config validation', () => { - it('validates `useCache`', () => { - newCacheId(); - const test = () => { - const indexes = addCacheInterceptors(ajax, { - // @ts-ignore needed for test - useCache: 'fakeUseCacheType', - }); - removeCacheInterceptors(ajax, indexes); - }; - expect(test).to.throw(); - }); - - it('validates property `timeToLive` throws if not type `number`', () => { - newCacheId(); - expect(() => { - const indexes = addCacheInterceptors(ajax, { - useCache: true, - // @ts-ignore needed for test - timeToLive: '', - }); - removeCacheInterceptors(ajax, indexes); - }).to.throw(); - }); - - it('validates cache identifier function', () => { - // @ts-ignore needed for test - cacheId = ''; - - const indexes = addCacheInterceptors(ajax, { useCache: true }); - - return ajax.fetch('/test').catch( - /** @param {Error} err */ err => { - expect(err.message).to.equal('getCacheIdentifier returns falsy'); - - removeCacheInterceptors(ajax, indexes); - }, - ); - }); - - it("throws when using methods other than `['get']`", () => { - newCacheId(); - - expect(() => { - const indexes = addCacheInterceptors(ajax, { - useCache: true, - methods: ['get', 'post'], - }); - removeCacheInterceptors(ajax, indexes); - }).to.throw(/not yet supported/); - }); - - it('throws error when requestIdentificationFn is not a function', () => { - newCacheId(); - - expect(() => { - const indexes = addCacheInterceptors(ajax, { - useCache: true, - // @ts-ignore needed for test - requestIdentificationFn: 'not a function', - }); - removeCacheInterceptors(ajax, indexes); - }).to.throw(/Property `requestIdentificationFn` must be of type `function`/); - }); - }); - - describe('Cached responses', () => { - it('returns the cached object on second call with `useCache: true`', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 100, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test'); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test')).to.be.true; - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(1); - - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('all calls with non-default `timeToLive` are cached proactively', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: false, - timeToLive: 100, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test'); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test')).to.be.true; - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(2); - await ajax.fetch('/test', { - cacheOptions: { - useCache: true, - }, - }); - expect(fetchStub.callCount).to.equal(2); - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 100, - }); - - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test', { - params: { - q: 'test', - page: 1, - }, - }); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test')).to.be.true; - await ajax.fetch('/test', { - params: { - q: 'test', - page: 1, - }, - }); - expect(fetchStub.callCount).to.equal(1); - // a request with different param should not be cached - await ajax.fetch('/test', { - params: { - q: 'test', - page: 2, - }, - }); - expect(fetchStub.callCount).to.equal(2); - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('uses cache when inside `timeToLive: 5000` window', async () => { - newCacheId(); - const clock = useFakeTimers({ - shouldAdvanceTime: true, - }); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 5000, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test'); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test')).to.be.true; - expect(fetchStub.callCount).to.equal(1); - clock.tick(4900); - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(1); - clock.tick(5100); - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(2); - ajaxRequestSpy.restore(); - clock.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('uses custom requestIdentificationFn when passed', async () => { - newCacheId(); - - const customRequestIdFn = /** @type {RequestIdentificationFn} */ (request, serializer) => { - let serializedRequestParams = ''; - if (request.params) { - serializedRequestParams = `?${serializer(request.params)}`; - } - return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get( - 'x-id', - )}${serializedRequestParams}`; - }; - const reqIdSpy = spy(customRequestIdFn); - const indexes = addCacheInterceptors(ajax, { - useCache: true, - requestIdentificationFn: reqIdSpy, - }); - - await ajax.fetch('/test', { headers: { 'x-id': '1' } }); - expect(reqIdSpy.calledOnce); - expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`); - removeCacheInterceptors(ajax, indexes); - }); - }); - - describe('Cache invalidation', () => { - it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 1000, - invalidateUrlsRegex: /foo/gi, - }); - - await ajax.fetch('/test'); // new url - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/test'); // cached - expect(fetchStub.callCount).to.equal(1); - - await ajax.fetch('/foo-request-1'); // new url - expect(fetchStub.callCount).to.equal(2); - await ajax.fetch('/foo-request-1'); // cached - expect(fetchStub.callCount).to.equal(2); - - await ajax.fetch('/foo-request-3'); // new url - expect(fetchStub.callCount).to.equal(3); - - await ajax.fetch('/test', { method: 'POST' }); // clear cache - expect(fetchStub.callCount).to.equal(4); - await ajax.fetch('/foo-request-1'); // not cached anymore - expect(fetchStub.callCount).to.equal(5); - await ajax.fetch('/foo-request-2'); // not cached anymore - expect(fetchStub.callCount).to.equal(6); - - removeCacheInterceptors(ajax, indexes); - }); - - it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 1000, - invalidateUrlsRegex: /posts/gi, - }); - - await ajax.fetch('/test'); - await ajax.fetch('/test'); // cached - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/posts'); - expect(fetchStub.callCount).to.equal(2); - await ajax.fetch('/posts'); // cached - expect(fetchStub.callCount).to.equal(2); - await ajax.fetch('/posts/1'); - expect(fetchStub.callCount).to.equal(3); - await ajax.fetch('/posts/1'); // cached - expect(fetchStub.callCount).to.equal(3); - // cleans cache for defined urls - await ajax.fetch('/test', { method: 'POST' }); - expect(fetchStub.callCount).to.equal(4); - await ajax.fetch('/posts'); // no longer cached => new request - expect(fetchStub.callCount).to.equal(5); - await ajax.fetch('/posts/1'); // no longer cached => new request - expect(fetchStub.callCount).to.equal(6); - - removeCacheInterceptors(ajax, indexes); - }); - - it('deletes cache after one hour', async () => { - newCacheId(); - const clock = useFakeTimers({ - shouldAdvanceTime: true, - }); - - const ajaxRequestSpy = spy(ajax, 'fetch'); - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 1000 * 60 * 60, - }); - - await ajax.fetch('/test-hour'); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true; - expect(fetchStub.callCount).to.equal(1); - clock.tick(1000 * 60 * 59); // 0:59 hour - await ajax.fetch('/test-hour'); - expect(fetchStub.callCount).to.equal(1); - clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour - await ajax.fetch('/test-hour'); - expect(fetchStub.callCount).to.equal(2); - ajaxRequestSpy.restore(); - clock.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('invalidates invalidateUrls endpoints', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 500, - }); - - const actionConfig = { - cacheOptions: { - invalidateUrls: ['/test-invalid-url'], - }, - }; - - await ajax.fetch('/test-valid-url', { ...actionConfig }); - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/test-invalid-url'); - expect(fetchStub.callCount).to.equal(2); - // 'post' will invalidate 'own' cache and the one mentioned in config - await ajax.fetch('/test-valid-url', { ...actionConfig, method: 'POST' }); - expect(fetchStub.callCount).to.equal(3); - await ajax.fetch('/test-invalid-url'); - // indicates that 'test-invalid-url' cache was removed - // because the server registered new request - expect(fetchStub.callCount).to.equal(4); - removeCacheInterceptors(ajax, indexes); - }); - - it('invalidates cache on a post', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 100, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test-post'); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; - expect(fetchStub.callCount).to.equal(1); - await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' }); - expect(ajaxRequestSpy.calledTwice).to.be.true; - expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; - expect(fetchStub.callCount).to.equal(2); - await ajax.fetch('/test-post'); - expect(fetchStub.callCount).to.equal(3); - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('caches response but does not return it when expiration time is 0', async () => { - newCacheId(); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 0, - }); - - const ajaxRequestSpy = spy(ajax, 'fetch'); - - await ajax.fetch('/test'); - const clock = useFakeTimers(); - expect(ajaxRequestSpy.calledOnce).to.be.true; - expect(ajaxRequestSpy.calledWith('/test')).to.be.true; - clock.tick(1); - clock.restore(); - await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(2); - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('does not use cache when `useCache: false` in the action', async () => { - newCacheId(); - getCacheIdentifier = () => 'cacheIdentifier2'; - - const ajaxAlwaysRequestSpy = spy(ajax, 'fetch'); - const indexes = addCacheInterceptors(ajax, { useCache: true }); - - await ajax.fetch('/test'); - expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true; - expect(ajaxAlwaysRequestSpy.calledWith('/test')); - await ajax.fetch('/test', { cacheOptions: { useCache: false } }); - expect(fetchStub.callCount).to.equal(2); - ajaxAlwaysRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('caches concurrent requests', async () => { - newCacheId(); - - let i = 0; - fetchStub.returns( - new Promise(resolve => { - i += 1; - setTimeout(() => { - resolve(new Response(`mock response ${i}`)); - }, 5); - }), - ); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 100, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - const request1 = ajax.fetch('/test'); - const request2 = ajax.fetch('/test'); - await aTimeout(1); - const request3 = ajax.fetch('/test'); - await aTimeout(3); - const request4 = ajax.fetch('/test'); - const responses = await Promise.all([request1, request2, request3, request4]); - expect(fetchStub.callCount).to.equal(1); - const responseTexts = await Promise.all(responses.map(r => r.text())); - expect(responseTexts).to.eql([ - 'mock response 1', - 'mock response 1', - 'mock response 1', - 'mock response 1', - ]); - - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - - it('preserves status and headers when returning cached response', async () => { - newCacheId(); - fetchStub.returns( - Promise.resolve( - new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }), - ), - ); - - const indexes = addCacheInterceptors(ajax, { - useCache: true, - timeToLive: 100, - }); - const ajaxRequestSpy = spy(ajax, 'fetch'); - - const response1 = await ajax.fetch('/test'); - const response2 = await ajax.fetch('/test'); - expect(fetchStub.callCount).to.equal(1); - expect(response1.status).to.equal(206); - expect(response1.headers.get('x-foo')).to.equal('x-bar'); - expect(response2.status).to.equal(206); - expect(response2.headers.get('x-foo')).to.equal('x-bar'); - - ajaxRequestSpy.restore(); - removeCacheInterceptors(ajax, indexes); - }); - }); - }); -}); diff --git a/packages/ajax/test/interceptors/acceptLanguageHeader.test.js b/packages/ajax/test/interceptors/acceptLanguageHeader.test.js new file mode 100644 index 000000000..a0f64de4a --- /dev/null +++ b/packages/ajax/test/interceptors/acceptLanguageHeader.test.js @@ -0,0 +1,16 @@ +import { expect } from '@open-wc/testing'; +import { acceptLanguageRequestInterceptor } from '../../src/interceptors/acceptLanguageHeader.js'; + +describe('acceptLanguageRequestInterceptor()', () => { + it('adds the locale as accept-language header', () => { + const request = new Request('/foo/'); + acceptLanguageRequestInterceptor(request); + expect(request.headers.get('accept-language')).to.equal('en'); + }); + + it('does not change an existing accept-language header', () => { + const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } }); + acceptLanguageRequestInterceptor(request); + expect(request.headers.get('accept-language')).to.equal('my-accept'); + }); +}); diff --git a/packages/ajax/test/interceptors/cacheInterceptors.test.js b/packages/ajax/test/interceptors/cacheInterceptors.test.js new file mode 100644 index 000000000..da9157f83 --- /dev/null +++ b/packages/ajax/test/interceptors/cacheInterceptors.test.js @@ -0,0 +1,595 @@ +import { expect } from '@open-wc/testing'; +import * as sinon from 'sinon'; +import '../../src/typedef.js'; +import { Ajax } from '../../index.js'; +import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js'; +import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js'; + +/** @type {Ajax} */ +let ajax; + +describe('cache interceptors', () => { + /** + * @param {number | undefined} timeout + * @param {number} i + */ + const returnResponseOnTick = (timeout, i) => + new Promise(resolve => + window.setTimeout(() => resolve(new Response(`mock response ${i}`)), timeout), + ); + + /** @type {number | undefined} */ + let cacheId; + /** @type {sinon.SinonStub} */ + let fetchStub; + const getCacheIdentifier = () => String(cacheId); + /** @type {sinon.SinonSpy} */ + let ajaxRequestSpy; + + const newCacheId = () => { + if (!cacheId) { + cacheId = 1; + } else { + cacheId += 1; + } + return cacheId; + }; + + /** + * @param {Ajax} ajaxInstance + * @param {CacheOptions} options + */ + const addCacheInterceptors = (ajaxInstance, options) => { + const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors( + getCacheIdentifier, + options, + ); + + ajaxInstance._requestInterceptors.push(cacheRequestInterceptor); + ajaxInstance._responseInterceptors.push(cacheResponseInterceptor); + }; + + beforeEach(() => { + ajax = new Ajax(); + fetchStub = sinon.stub(window, 'fetch'); + fetchStub.returns(Promise.resolve(new Response('mock response'))); + ajaxRequestSpy = sinon.spy(ajax, 'fetch'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('Original ajax instance', () => { + it('allows direct ajax calls without cache interceptors configured', async () => { + await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(1); + await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(2); + }); + }); + + describe('Cache config validation', () => { + it('validates `useCache`', () => { + newCacheId(); + const test = () => { + addCacheInterceptors(ajax, { + // @ts-ignore needed for test + useCache: 'fakeUseCacheType', + }); + }; + expect(test).to.throw(); + }); + + it('validates property `maxAge` throws if not type `number`', () => { + newCacheId(); + expect(() => { + addCacheInterceptors(ajax, { + useCache: true, + // @ts-ignore needed for test + maxAge: '', + }); + }).to.throw(); + }); + + it('validates cache identifier function', async () => { + const cacheSessionId = cacheId; + // @ts-ignore needed for test + cacheId = ''; + + addCacheInterceptors(ajax, { useCache: true }); + await ajax + .fetch('/test') + .then(() => expect.fail('fetch should not resolve here')) + .catch( + /** @param {Error} err */ err => { + expect(err.message).to.equal('Invalid cache identifier'); + }, + ) + .finally(() => {}); + cacheId = cacheSessionId; + }); + + it("throws when using methods other than `['get']`", () => { + newCacheId(); + + expect(() => { + addCacheInterceptors(ajax, { + useCache: true, + methods: ['get', 'post'], + }); + }).to.throw(/Cache can only be utilized with `GET` method/); + }); + + it('throws error when requestIdFunction is not a function', () => { + newCacheId(); + + expect(() => { + addCacheInterceptors(ajax, { + useCache: true, + // @ts-ignore needed for test + requestIdFunction: 'not a function', + }); + }).to.throw(/Property `requestIdFunction` must be a `function`/); + }); + }); + + describe('Cached responses', () => { + it('returns the cached object on second call with `useCache: true`', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 100, + }); + + await ajax.fetch('/test'); + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(1); + }); + + // TODO: Check if this is the behaviour we want + it('all calls with non-default `maxAge` are cached proactively', async () => { + // Given + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: false, + maxAge: 100, + }); + + // When + await ajax.fetch('/test'); + + // Then + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + + // When + await ajax.fetch('/test', { + cacheOptions: { + useCache: true, + }, + }); + + // Then + expect(fetchStub.callCount).to.equal(2); + + // When + await ajax.fetch('/test', { + cacheOptions: { + useCache: true, + }, + }); + + // Then + expect(fetchStub.callCount).to.equal(2); + }); + + it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => { + // Given + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 100, + }); + + // When + await ajax.fetch('/test', { + params: { + q: 'test', + page: 1, + }, + }); + + // Then + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + + // When + await ajax.fetch('/test', { + params: { + q: 'test', + page: 1, + }, + }); + + // Then + expect(fetchStub.callCount).to.equal(1); + + // a request with different param should not be cached + + // When + await ajax.fetch('/test', { + params: { + q: 'test', + page: 2, + }, + }); + + // Then + expect(fetchStub.callCount).to.equal(2); + }); + + it('uses cache when inside `maxAge: 5000` window', async () => { + newCacheId(); + const clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 5000, + }); + + await ajax.fetch('/test'); + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + clock.tick(4900); + await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(1); + clock.tick(5100); + await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(2); + clock.restore(); + }); + + it('uses custom requestIdFunction when passed', async () => { + newCacheId(); + + const customRequestIdFn = /** @type {RequestIdFunction} */ (request, serializer) => { + let serializedRequestParams = ''; + if (request.params) { + // @ts-ignore assume serializer is defined + serializedRequestParams = `?${serializer(request.params)}`; + } + return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get( + 'x-id', + )}${serializedRequestParams}`; + }; + const reqIdSpy = sinon.spy(customRequestIdFn); + addCacheInterceptors(ajax, { + useCache: true, + requestIdFunction: reqIdSpy, + }); + + await ajax.fetch('/test', { headers: { 'x-id': '1' } }); + expect(reqIdSpy.calledOnce); + expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`); + }); + + it('throws when the request object is missing from the response', async () => { + const { cacheResponseInterceptor } = createCacheInterceptors(() => 'cache-id', {}); + + // @ts-ignore not an actual valid CacheResponse object + await cacheResponseInterceptor({}) + .then(() => expect.fail('cacheResponseInterceptor should not resolve here')) + .catch( + /** @param {Error} err */ err => { + expect(err.message).to.equal('Missing request in response'); + }, + ); + + // @ts-ignore not an actual valid CacheResponse object + await cacheResponseInterceptor({ request: { method: 'get' } }) + .then(() => expect('everything').to.be.ok) + .catch(err => + expect.fail( + `cacheResponseInterceptor should resolve here, but threw an error: ${err.message}`, + ), + ); + }); + }); + + describe('Cache invalidation', () => { + it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 1000, + invalidateUrlsRegex: /foo/gi, + }); + + await ajax.fetch('/test'); // new url + expect(fetchStub.callCount).to.equal(1); + await ajax.fetch('/test'); // cached + expect(fetchStub.callCount).to.equal(1); + + await ajax.fetch('/foo-request-1'); // new url + expect(fetchStub.callCount).to.equal(2); + await ajax.fetch('/foo-request-1'); // cached + expect(fetchStub.callCount).to.equal(2); + + await ajax.fetch('/foo-request-3'); // new url + expect(fetchStub.callCount).to.equal(3); + + await ajax.fetch('/test', { method: 'POST' }); // clear cache + expect(fetchStub.callCount).to.equal(4); + await ajax.fetch('/foo-request-1'); // not cached anymore + expect(fetchStub.callCount).to.equal(5); + await ajax.fetch('/foo-request-2'); // not cached anymore + expect(fetchStub.callCount).to.equal(6); + }); + + it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 1000, + invalidateUrlsRegex: /posts/gi, + }); + + await ajax.fetch('/test'); + await ajax.fetch('/test'); // cached + expect(fetchStub.callCount).to.equal(1); + await ajax.fetch('/posts'); + expect(fetchStub.callCount).to.equal(2); + await ajax.fetch('/posts'); // cached + expect(fetchStub.callCount).to.equal(2); + await ajax.fetch('/posts/1'); + expect(fetchStub.callCount).to.equal(3); + await ajax.fetch('/posts/1'); // cached + expect(fetchStub.callCount).to.equal(3); + // cleans cache for defined urls + await ajax.fetch('/test', { method: 'POST' }); + expect(fetchStub.callCount).to.equal(4); + await ajax.fetch('/posts'); // no longer cached => new request + expect(fetchStub.callCount).to.equal(5); + await ajax.fetch('/posts/1'); // no longer cached => new request + expect(fetchStub.callCount).to.equal(6); + }); + + it('deletes cache after one hour', async () => { + newCacheId(); + const clock = sinon.useFakeTimers({ + shouldAdvanceTime: true, + }); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 1000 * 60 * 60, + }); + + await ajax.fetch('/test-hour'); + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + clock.tick(1000 * 60 * 59); // 0:59 hour + await ajax.fetch('/test-hour'); + expect(fetchStub.callCount).to.equal(1); + clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour + await ajax.fetch('/test-hour'); + expect(fetchStub.callCount).to.equal(2); + clock.restore(); + }); + + it('invalidates invalidateUrls endpoints', async () => { + const { requestIdFunction } = extendCacheOptions({}); + + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 500, + }); + + const cacheOptions = { + invalidateUrls: [ + requestIdFunction({ + url: new URL('/test-invalid-url', window.location.href).toString(), + params: { foo: 1, bar: 2 }, + }), + ], + }; + + await ajax.fetch('/test-valid-url', { cacheOptions }); + expect(fetchStub.callCount).to.equal(1); + + await ajax.fetch('/test-invalid-url?foo=1&bar=2'); + expect(fetchStub.callCount).to.equal(2); + + await ajax.fetch('/test-invalid-url?foo=1&bar=2'); + expect(fetchStub.callCount).to.equal(2); + + // 'post' will invalidate 'own' cache and the one mentioned in config + await ajax.fetch('/test-valid-url', { cacheOptions, method: 'POST' }); + expect(fetchStub.callCount).to.equal(3); + + await ajax.fetch('/test-invalid-url?foo=1&bar=2'); + // indicates that 'test-invalid-url' cache was removed + // because the server registered new request + expect(fetchStub.callCount).to.equal(4); + }); + + it('invalidates cache on a post', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 100, + }); + + await ajax.fetch('/test-post'); + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' }); + expect(ajaxRequestSpy.calledTwice).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; + expect(fetchStub.callCount).to.equal(2); + await ajax.fetch('/test-post'); + expect(fetchStub.callCount).to.equal(3); + }); + + it('caches response but does not return it when expiration time is 0', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 0, + }); + + const clock = sinon.useFakeTimers(); + + await ajax.fetch('/test'); + + expect(ajaxRequestSpy.calledOnce).to.be.true; + + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + + clock.tick(1); + + await ajax.fetch('/test'); + + clock.restore(); + + expect(fetchStub.callCount).to.equal(2); + }); + + it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => { + // Given + addCacheInterceptors(ajax, { useCache: true }); + + // When + await ajax.fetch('/test'); + await ajax.fetch('/test'); + + // Then + expect(fetchStub.callCount).to.equal(1); + + // When + await ajax.fetch('/test', { cacheOptions: { useCache: false } }); + + // Then + expect(fetchStub.callCount).to.equal(2); + }); + + it('caches concurrent requests', async () => { + newCacheId(); + + const clock = sinon.useFakeTimers(); + + fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1)); + fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2)); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 750, + }); + + const firstRequest = ajax.fetch('/test').then(r => r.text()); + const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text()); + const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text()); + + clock.tick(1000); + + // firstRequest is cached at tick 1000 in the next line! + const firstResponses = await Promise.all([ + firstRequest, + concurrentFirstRequest1, + concurrentFirstRequest2, + ]); + + expect(fetchStub.callCount).to.equal(1); + + const cachedFirstRequest = ajax.fetch('/test').then(r => r.text()); + + clock.tick(500); + + const cachedFirstResponse = await cachedFirstRequest; + + expect(fetchStub.callCount).to.equal(1); + + const secondRequest = ajax.fetch('/test').then(r => r.text()); + const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text()); + + clock.tick(1000); + + const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]); + + expect(fetchStub.callCount).to.equal(2); + + expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']); + + expect(cachedFirstResponse).to.equal('mock response 1'); + + expect(secondResponses).to.eql(['mock response 2', 'mock response 2']); + }); + + it('discards responses that are requested in a different cache session', async () => { + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 10000, + }); + + // Switch the cache after the cache request interceptor, but before the fetch + // @ts-ignore + ajax._requestInterceptors.push(async request => { + newCacheId(); + resetCacheSession(getCacheIdentifier()); + return request; + }); + + const firstRequest = ajax.fetch('/test').then(r => r.text()); + + const firstResponse = await firstRequest; + + expect(firstResponse).to.equal('mock response'); + // @ts-ignore + expect(ajaxCache._cachedRequests).to.deep.equal({}); + expect(fetchStub.callCount).to.equal(1); + }); + + it('preserves status and headers when returning cached response', async () => { + newCacheId(); + fetchStub.returns( + Promise.resolve( + new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }), + ), + ); + + addCacheInterceptors(ajax, { + useCache: true, + maxAge: 100, + }); + + const response1 = await ajax.fetch('/test'); + const response2 = await ajax.fetch('/test'); + expect(fetchStub.callCount).to.equal(1); + expect(response1.status).to.equal(206); + expect(response1.headers.get('x-foo')).to.equal('x-bar'); + expect(response2.status).to.equal(206); + expect(response2.headers.get('x-foo')).to.equal('x-bar'); + }); + }); +}); diff --git a/packages/ajax/test/interceptors/interceptors.test.js b/packages/ajax/test/interceptors/interceptors.test.js new file mode 100644 index 000000000..e47c1d0c6 --- /dev/null +++ b/packages/ajax/test/interceptors/interceptors.test.js @@ -0,0 +1,16 @@ +import { expect } from '@open-wc/testing'; +import * as interceptors from '../../src/interceptors/index.js'; + +describe('interceptors interface', () => { + it('exposes the acceptLanguageRequestInterceptor function', () => { + expect(interceptors.acceptLanguageRequestInterceptor).to.be.a('Function'); + }); + + it('exposes the createXsrfRequestInterceptor function', () => { + expect(interceptors.createXsrfRequestInterceptor).to.be.a('Function'); + }); + + it('exposes the createCacheInterceptors function', () => { + expect(interceptors.createCacheInterceptors).to.be.a('Function'); + }); +}); diff --git a/packages/ajax/test/interceptors/xsrfHeader.test.js b/packages/ajax/test/interceptors/xsrfHeader.test.js new file mode 100644 index 000000000..938d26082 --- /dev/null +++ b/packages/ajax/test/interceptors/xsrfHeader.test.js @@ -0,0 +1,42 @@ +import { expect } from '@open-wc/testing'; +import { createXsrfRequestInterceptor, getCookie } from '../../src/interceptors/xsrfHeader.js'; + +describe('getCookie()', () => { + it('returns the cookie value', () => { + expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar'); + }); + + it('returns the cookie value when there are multiple cookies', () => { + expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar'); + }); + + it('returns null when the cookie cannot be found', () => { + expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null); + }); + + it('decodes the cookie vaue', () => { + expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal( + '/foo/ bar "', + ); + }); +}); + +describe('createXsrfRequestInterceptor()', () => { + it('adds the xsrf token header to the request', () => { + const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { + cookie: 'XSRF-TOKEN=foo', + }); + const request = new Request('/foo/'); + interceptor(request); + expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo'); + }); + + it('does not set anything if the cookie is not there', () => { + const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', { + cookie: 'XXSRF-TOKEN=foo', + }); + const request = new Request('/foo/'); + interceptor(request); + expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null); + }); +}); diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts index ba4e9494a..0a8a231ab 100644 --- a/packages/ajax/types/types.d.ts +++ b/packages/ajax/types/types.d.ts @@ -27,18 +27,18 @@ export interface CacheConfig { export type Params = { [key: string]: any }; -export type RequestIdentificationFn = ( +export type RequestIdFunction = ( request: Partial, - stringifySearchParams: (params: Params) => string, + serializeSearchParams?: (params: Params) => string, ) => string; export interface CacheOptions { useCache?: boolean; methods?: string[]; - timeToLive?: number; + maxAge?: number; invalidateUrls?: string[]; invalidateUrlsRegex?: RegExp; - requestIdentificationFn?: RequestIdentificationFn; + requestIdFunction?: RequestIdFunction; } export interface CacheOptionsWithIdentifier extends CacheOptions { @@ -48,11 +48,12 @@ export interface CacheOptionsWithIdentifier extends CacheOptions { export interface ValidatedCacheOptions extends CacheOptions { useCache: boolean; methods: string[]; - timeToLive: number; - requestIdentificationFn: RequestIdentificationFn; + maxAge: number; + requestIdFunction: RequestIdFunction; } export interface CacheRequestExtension { + cacheSessionId?: string; cacheOptions?: CacheOptions; adapter: any; status: number; @@ -61,6 +62,7 @@ export interface CacheRequestExtension { } export interface CacheResponseRequest { + cacheSessionId?: string; cacheOptions?: CacheOptions; method: string; } diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index c1db9a929..08d036eab 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -39,10 +39,8 @@ export default { playwrightLauncher({ product: 'chromium' }), playwrightLauncher({ product: 'webkit' }), ], - groups: packages.map(pkg => { - return { - name: pkg, - files: `packages/${pkg}/test/**/*.test.js`, - }; - }), + groups: packages.map(pkg => ({ + name: pkg, + files: `packages/${pkg}/test/**/*.test.js`, + })), };