@ethlete/query 6.0.0-next.44 → 6.0.0-next.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/fesm2022/ethlete-query-testing.mjs +138 -52
- package/fesm2022/ethlete-query-testing.mjs.map +1 -1
- package/fesm2022/ethlete-query.mjs +1978 -957
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/generators/migrate-to-query-v3/cleanup-migration.js +11 -10
- package/package.json +5 -5
- package/types/ethlete-query-testing.d.ts +37 -5
- package/types/ethlete-query.d.ts +312 -118
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
# @ethlete/query
|
|
2
2
|
|
|
3
|
+
## 6.0.0-next.46
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- The Angular peer dependency moves from 22.0.7 to 22.1.6. Angular 22.0.x is affected by
|
|
8
|
+
GHSA-hh8m-fm6v-7cvg, a sanitization bypass through directive host bindings, fixed in 22.1.0.
|
|
9
|
+
- `<et-query-devtools-lazy>` renders nothing without `provideQueryDevtools()` - no floating button, no
|
|
10
|
+
shortcut, no panel download - and the now-public `isQueryDevtoolsEnabled()` is what it gates on.
|
|
11
|
+
- A GraphQL `200` with `errors` and no `data` now fails with `ET601` carrying the server's errors, and the error-message ladder reads `{ errors: [{ message }] }` and `[{ message }]` bodies.
|
|
12
|
+
- GQL over POST sends `variables` as a JSON object, as the spec requires, instead of a JSON string. A server that relied on the string must accept the map. GET is unchanged.
|
|
13
|
+
- `withDefaultRetry()` and `withEthleteApiErrors()` now retry only the client they are on. Before, the first call installed the policy process-wide. Migration: add the feature to every client that should retry - a second client without it no longer retries.
|
|
14
|
+
- `defineQueryForm`: `branch()` now debounces and runs the reset graph like the source form and exposes `liveValue`; the filter overlay applies `liveValue` on submit.
|
|
15
|
+
- HTTP queries gain `createHeadQuery` and `createOptionsQuery` with their secure twins, and `createFakeQueryPersistenceStore()` can fail a removal with `failNextRemoves(count)`.
|
|
16
|
+
- WebSocket client: a room joined after the ping expired, or held across a recovered reconnect, is no longer joined twice. `WebSocketClientSocket` now requires `onAnyOutgoing` and `recovered`; add both to a hand-written fake.
|
|
17
|
+
- Auth: `withTokenExpirationWarning` reads the expiry claim named by its new `expiresInPropertyName` option, so a token that carries the expiry under another name no longer reports no expiry at all.
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- The panel drops a client's Cache, Faults and Events entries once that client's injector is destroyed,
|
|
22
|
+
instead of keeping them alive for as long as one of its queries has a tombstone.
|
|
23
|
+
- Query persistence now opens the IndexedDB database again after the browser closes the connection, for example when the user clears the site data. Before, every later write failed and persistence stopped for the rest of the session.
|
|
24
|
+
- Legacy `V2BearerAuthProvider`: a new token now replaces the pending refresh timer instead of adding a second, parallel refresh chain.
|
|
25
|
+
- Legacy query: variables that differ only by whitespace or braces inside a string, or by nesting, no longer share one cached query instance.
|
|
26
|
+
- Legacy `etInfinityQueryTrigger`: a trigger that stays visible after a page lands now keeps loading the next page instead of stalling.
|
|
27
|
+
- Legacy interop: `abort()` publishes `Cancelled`, a second `execute()` while loading is a no-op again, a poll reports `refreshing`, `triggerImmediately` runs at once, and `state$` replays `Prepared`.
|
|
28
|
+
- `createLegacyQueryCreator`: `prepare({ variables })` now forwards the variables to a wrapped gql creator.
|
|
29
|
+
- Legacy query: a secure query that refreshes after a 401 now stays loading until the retry lands, instead of reporting a failure first.
|
|
30
|
+
- Legacy query containers: teardown destroys an uncacheable query, stops the poll of the last container, tracks dependents per injector, tears down the store's listeners, and leaves a superseded mutation to settle.
|
|
31
|
+
- Legacy `QueryForm`: a value arriving through a navigation (back/forward, a link) now commits at once instead of waiting out the field's debounce, matching `defineQueryForm`.
|
|
32
|
+
- Legacy `QueryForm`: values track control writes without `observe()`, a commit no longer cancels an in-flight route change, and a committed value keeps its type through the form's own URL write.
|
|
33
|
+
- Legacy query: polling no longer resumes on window focus when its `takeUntil` notifier fired while polling was paused.
|
|
34
|
+
- Legacy query: a `false`, `0` or `''` body is now sent with its content type instead of being dropped.
|
|
35
|
+
- Legacy query: a secure query executed while logged out returns to `Prepared` instead of staying `Loading`, so executing it after login sends the request.
|
|
36
|
+
- Legacy query client: calling `setAuthProvider` again with the provider already set no longer cancels its scheduled token refresh.
|
|
37
|
+
- Legacy: `[etInfinityQueryTrigger]` no longer throws NG0200, `v2BuildQueryCacheKey` takes the method, `setAuthProvider` keeps the refresh cookie, the prepare fallback survives several applications, and the barrel's re-exports carry `@deprecated`.
|
|
38
|
+
- `validateWithV2Query`: a secure validate query run while logged out now reports a form-level error instead of leaving the field pending forever.
|
|
39
|
+
- `validateWithV2Query`: a shared GET query cancelled by another consumer is re-run instead of leaving the field pending forever.
|
|
40
|
+
- `fetchNextPage()` on a paged stack returns `null` and keeps its position when its args repeat an already loaded page, like `fetchPreviousPage()` does.
|
|
41
|
+
- Multi-tab auth: a follower waits for the leader's in-flight refresh and spends one token per rotation, a cached login is no longer broadcast, and `withTracking` honors `trackInternalEvents`.
|
|
42
|
+
- Bearer auth: a login that races the cookie restore, an empty `2xx` body and a refresh that yields no tokens now settle the session instead of stranding it, and the cookie clears in every reachable scope.
|
|
43
|
+
- Caching: the key covers the request method and case-folds header names, and an entry's secure, refreshable and retention flags follow the queries still bound to it rather than the one that made it.
|
|
44
|
+
- Cache keys no longer merge request bodies that differ only in whitespace or braces inside strings (GraphQL POST variables like `'new york'` vs `'newyork'`); persisted entries of requests with a body miss once after upgrading.
|
|
45
|
+
- Query: the `ET800` circular-dependency guard counts only executions with identical args and measures its window with its own clock, so a fast-typed search no longer trips it.
|
|
46
|
+
- Destroying a query client's injector now releases its retained unused cache entries and their eviction timers.
|
|
47
|
+
- Query devtools: a tombstone drops the destroyed view, override recorders and host elements are released, the floating pills settle after first paint, and the Forms tab links every reader of a form.
|
|
48
|
+
- Error handling: a malformed `violations` body degrades to a form-level `etServerError` instead of throwing, and the `416`, `408`, `410` and `425` status texts are corrected.
|
|
49
|
+
- Query: `execute()` after destroy warns instead of throwing NG0205, `executeUntilSettled` settles on cancel, `reset()` detaches the request, a batch records a throwing item, and snapshots keep their own signals.
|
|
50
|
+
- A follower tab whose refresh takeover was made moot by a new token pair now re-asks and takes over its next unanswered delegated refresh again.
|
|
51
|
+
- Query forms: an array field with a non-null default now survives a reload holding an empty list or `null`, instead of restoring its default or `['ET_NULL__']`.
|
|
52
|
+
- Query forms: a date-only URL value like `2026-09-01` now reads as local midnight of that day instead of shifting a day west of UTC.
|
|
53
|
+
- A deprecated `QueryForm` field with a function `defaultValue` treats a value equal to its result as the default again, so it is neither written to the URL nor counted in `activeFilterCount$`.
|
|
54
|
+
- Query forms: URL writes no longer break under an app-level `defaultQueryParamsHandling` of `'merge'` or `'preserve'`.
|
|
55
|
+
- Query forms: writing to the URL no longer drops its `#fragment`.
|
|
56
|
+
- `defineQueryForm`: an emptied array commits as its `null` default, `skipResets` and `skipFields` no longer leak into the next change, a value written before `observe()` survives, and `liveValue` reports the controls.
|
|
57
|
+
- Query forms: `skipResets` now applies only to the fields that write changes, so a pending debounced edit to another field still resets its dependents.
|
|
58
|
+
- Query forms: `unobserve()` now commits a pending debounced edit to `value` instead of dropping it.
|
|
59
|
+
- Query forms: restoring an empty or unparseable URL value no longer counts as a change, so `?search=&page=3` keeps page 3 instead of resetting it.
|
|
60
|
+
- Query forms: `searchQueryField` restores `?search=2024` as a string, and a field with an array default restores a single URL value as a one-item array.
|
|
61
|
+
- `defineQueryForm`: two forms writing in one tick keep each other's params, `appendToUrl: false` leaves a foreign param alone, `0` reads back as a number, and a commit no longer cancels a route change.
|
|
62
|
+
- GraphQL: a document is parsed once and minified without breaking on comments or string literals, `rawResponse` can declare its own envelope, and secure queries over POST cache like the rest.
|
|
63
|
+
- GraphQL: the `operationName` sent with a document no longer comes from an operation that only appears in a comment above the real one.
|
|
64
|
+
- Query persistence: a response body IndexedDB cannot store no longer leaves a metadata record without a body on disk.
|
|
65
|
+
- Query persistence: mutations are never stored, one opted-in consumer is enough, GraphQL over POST persists, a failed IndexedDB open is retried, and a purge always beats a hydration or index load in flight.
|
|
66
|
+
- Query persistence: only a `QuotaExceededError` frees the oldest half of the store before the write is retried; any other write failure retries without deleting entries.
|
|
67
|
+
- Persistence: a tab no longer hydrates a body that a build with another `version` wrote over the same key; custom adapters should return `version` from `read`.
|
|
68
|
+
- Query persistence: entries written under another `version` are ignored instead of deleted, so two tabs on either side of a deploy no longer wipe each other's store; they are removed once past `maxAge`.
|
|
69
|
+
- Persistence: a logout now also removes secure responses another tab persisted after this one started.
|
|
70
|
+
- Persistence: secure responses are held back until the session is known and purged when a tab starts without one, so a session that ended without a logout no longer hydrates the previous user's data.
|
|
71
|
+
- Persistence: `whenPersistenceReady` now resolves when a custom adapter's `loadIndex` throws synchronously instead of rejecting.
|
|
72
|
+
- Persistent auth: a session now survives a page reload when `localStorage` refuses writes or is unavailable, instead of silently logging the user out.
|
|
73
|
+
- Query types: `QueryContext` and `setDefaultQueryRetryFn` leave the published types, `createQueryFeature()` takes any `type` string, and `createQuerySubmission`'s `onSuccess` allows the `null` a `204` hands it.
|
|
74
|
+
- `withRefreshQuery`'s `retryConfig.maxAttempts` now retries a failed refresh that many times, instead of one fewer.
|
|
75
|
+
- Query stacks and sequences: `deduplicateArgs` applies without `append`, each response runs its features once, `fetchPreviousPage()` returns the page it made, and every link reports the built `total`.
|
|
76
|
+
- `createQuerySubmission`: a custom `mapViolations` that returns `undefined` or `null` no longer gets the default violation mapping attached to the form.
|
|
77
|
+
- Testing entry point: `setupQueryTest` now installs one console filter instead of nesting a
|
|
78
|
+
new one per call, and exposes `restoreConsole`. `installFakeWebLocks` removes the `abort`
|
|
79
|
+
listeners it adds. The websocket double reports `withCredentials()`.
|
|
80
|
+
- Query: a `transformResponse` that throws lands in `error()` with code `0`, keeps the last good `response()`, and no longer re-runs `withSuccessHandling`.
|
|
81
|
+
- `validateWithQuery`: the in-flight request stops when the field goes idle, and the internal query's cache entry and resource are released with the form that owns them.
|
|
82
|
+
- `validateWithQuery` / `validateWithV2Query`: a network or other non-violation error keeps its form-level error when a custom `mapViolations` is set, instead of validating the field as clean.
|
|
83
|
+
- WebSockets: a room joined before the socket connects sends one `join-room`, and a frame carrying no `room` string is reported as malformed without flooding production logs.
|
|
84
|
+
- The devtools session vault no longer uses `localStorage` outside a development build, so a deployed
|
|
85
|
+
app can mount the panel without leaving tokens and credentials behind.
|
|
86
|
+
- A room left while the web socket is down no longer sends a `leave-room` to a fresh session that never joined it; after a recovered reconnect the leave is still sent.
|
|
87
|
+
|
|
88
|
+
## 6.0.0-next.45
|
|
89
|
+
|
|
90
|
+
### Patch Changes
|
|
91
|
+
|
|
92
|
+
- [`cd8956e`](https://github.com/ethlete-io/ethdk/commit/cd8956ed2303074e4de564e6a2763e385acb17ee) Thanks [@TomTomB](https://github.com/TomTomB)! - Query: `withPersistentAuth` no longer leaves a second auth cookie behind on a subdomain, and logout removes the one it wrote.
|
|
93
|
+
|
|
3
94
|
## 6.0.0-next.44
|
|
4
95
|
|
|
5
96
|
### Patch Changes
|
|
@@ -182,6 +182,7 @@ const installFakeWebLocks = () => {
|
|
|
182
182
|
// of the tab it was taken from settles afterwards.
|
|
183
183
|
if (holders.get(name) === waiter)
|
|
184
184
|
holders.delete(name);
|
|
185
|
+
waiter.releaseSignal();
|
|
185
186
|
settleWaiter();
|
|
186
187
|
pump(name);
|
|
187
188
|
};
|
|
@@ -203,15 +204,24 @@ const installFakeWebLocks = () => {
|
|
|
203
204
|
reject(abortError());
|
|
204
205
|
return;
|
|
205
206
|
}
|
|
206
|
-
const waiter = { callback, resolve, reject, granted: false };
|
|
207
|
-
options.signal
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
const waiter = { callback, resolve, reject, granted: false, releaseSignal: () => undefined };
|
|
208
|
+
const signal = options.signal;
|
|
209
|
+
if (signal) {
|
|
210
|
+
const onAbort = () => {
|
|
211
|
+
waiter.releaseSignal();
|
|
212
|
+
if (waiter.granted)
|
|
213
|
+
return;
|
|
214
|
+
const queue = queues.get(name);
|
|
215
|
+
if (queue)
|
|
216
|
+
queues.set(name, queue.filter((entry) => entry !== waiter));
|
|
217
|
+
reject(abortError());
|
|
218
|
+
};
|
|
219
|
+
signal.addEventListener('abort', onAbort);
|
|
220
|
+
waiter.releaseSignal = () => {
|
|
221
|
+
waiter.releaseSignal = () => undefined;
|
|
222
|
+
signal.removeEventListener('abort', onAbort);
|
|
223
|
+
};
|
|
224
|
+
}
|
|
215
225
|
if (options.steal) {
|
|
216
226
|
queueMicrotask(() => {
|
|
217
227
|
const holder = holders.get(name);
|
|
@@ -229,6 +239,7 @@ const installFakeWebLocks = () => {
|
|
|
229
239
|
if (options.ifAvailable) {
|
|
230
240
|
queueMicrotask(() => {
|
|
231
241
|
if (holders.has(name) || (queues.get(name)?.length ?? 0) > 0) {
|
|
242
|
+
waiter.releaseSignal();
|
|
232
243
|
Promise.resolve(callback(null)).then(resolve, reject);
|
|
233
244
|
return;
|
|
234
245
|
}
|
|
@@ -259,6 +270,8 @@ const installFakeWebLocks = () => {
|
|
|
259
270
|
heldNames: () => [...holders.keys()],
|
|
260
271
|
pendingNames: () => [...queues.entries()].filter(([, queue]) => queue.length > 0).map(([name]) => name),
|
|
261
272
|
restore: () => {
|
|
273
|
+
for (const waiter of [...holders.values(), ...[...queues.values()].flat()])
|
|
274
|
+
waiter.releaseSignal();
|
|
262
275
|
holders.clear();
|
|
263
276
|
queues.clear();
|
|
264
277
|
if (originalDescriptor) {
|
|
@@ -282,10 +295,14 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
282
295
|
const store = new Map();
|
|
283
296
|
const calls = { loadIndex: 0, read: 0, write: 0, remove: 0, clear: 0 };
|
|
284
297
|
const deferredReads = [];
|
|
298
|
+
const deferredLoadIndexes = [];
|
|
285
299
|
let failingWrites = 0;
|
|
286
300
|
let writeError = null;
|
|
301
|
+
let failingRemoves = 0;
|
|
302
|
+
let removeError = null;
|
|
287
303
|
let loadIndexError = null;
|
|
288
304
|
let isDeferringReads = false;
|
|
305
|
+
let isDeferringLoadIndex = false;
|
|
289
306
|
// Cloning on the way in and on the way out is not pedantry: a real store hands back a copy, so a
|
|
290
307
|
// spec (or the engine) mutating a hydrated body must not be able to change what is "on disk".
|
|
291
308
|
const clone = (value) => structuredClone(value);
|
|
@@ -296,7 +313,13 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
296
313
|
loadIndexError = null;
|
|
297
314
|
throw error;
|
|
298
315
|
}
|
|
299
|
-
|
|
316
|
+
// Taken before the gate, not after: a real store answers with what it held when the read started,
|
|
317
|
+
// so anything that empties it while a deferred load is held must not change what that load returns.
|
|
318
|
+
const snapshot = Array.from(store.values()).map(({ body: _body, ...meta }) => clone(meta));
|
|
319
|
+
if (isDeferringLoadIndex) {
|
|
320
|
+
await new Promise((resolve) => deferredLoadIndexes.push(resolve));
|
|
321
|
+
}
|
|
322
|
+
return snapshot;
|
|
300
323
|
};
|
|
301
324
|
const read = async (key) => {
|
|
302
325
|
calls.read++;
|
|
@@ -304,7 +327,7 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
304
327
|
await new Promise((resolve) => deferredReads.push(resolve));
|
|
305
328
|
}
|
|
306
329
|
const entry = store.get(key);
|
|
307
|
-
return entry ? { body: clone(entry.body) } : null;
|
|
330
|
+
return entry ? { body: clone(entry.body), version: entry.version } : null;
|
|
308
331
|
};
|
|
309
332
|
const write = async (entries) => {
|
|
310
333
|
calls.write++;
|
|
@@ -318,6 +341,10 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
318
341
|
};
|
|
319
342
|
const remove = async (keys) => {
|
|
320
343
|
calls.remove++;
|
|
344
|
+
if (failingRemoves > 0) {
|
|
345
|
+
failingRemoves--;
|
|
346
|
+
throw removeError ?? new Error('The removal failed.');
|
|
347
|
+
}
|
|
321
348
|
for (const key of keys) {
|
|
322
349
|
store.delete(key);
|
|
323
350
|
}
|
|
@@ -343,6 +370,10 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
343
370
|
failingWrites = count;
|
|
344
371
|
writeError = error ?? null;
|
|
345
372
|
},
|
|
373
|
+
failNextRemoves: (count, error) => {
|
|
374
|
+
failingRemoves = count;
|
|
375
|
+
removeError = error ?? null;
|
|
376
|
+
},
|
|
346
377
|
failNextLoadIndex: (error) => {
|
|
347
378
|
loadIndexError = error ?? quotaError();
|
|
348
379
|
},
|
|
@@ -357,40 +388,65 @@ const createFakeQueryPersistenceStore = () => {
|
|
|
357
388
|
}
|
|
358
389
|
await Promise.resolve();
|
|
359
390
|
},
|
|
391
|
+
deferLoadIndex: () => {
|
|
392
|
+
isDeferringLoadIndex = true;
|
|
393
|
+
},
|
|
394
|
+
flushLoadIndex: async () => {
|
|
395
|
+
isDeferringLoadIndex = false;
|
|
396
|
+
const pending = deferredLoadIndexes.splice(0, deferredLoadIndexes.length);
|
|
397
|
+
for (const resolve of pending) {
|
|
398
|
+
resolve();
|
|
399
|
+
}
|
|
400
|
+
await Promise.resolve();
|
|
401
|
+
},
|
|
360
402
|
};
|
|
361
403
|
};
|
|
362
404
|
|
|
405
|
+
let originalWarn = null;
|
|
406
|
+
let originalError = null;
|
|
407
|
+
const filteredWarn = (...args) => {
|
|
408
|
+
const message = args[0];
|
|
409
|
+
if (typeof message === 'string' && message.includes('auto-refresh')) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
originalWarn?.(...args);
|
|
413
|
+
};
|
|
414
|
+
const filteredError = (...args) => {
|
|
415
|
+
const message = args[0];
|
|
416
|
+
if (message && typeof message === 'object' && 'name' in message && message.name === 'HttpErrorResponse') {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (typeof message === 'string' && message.includes('Failed to decrypt bearer token')) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (typeof message === 'string' && message.includes('Failed to extract tokens from')) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
originalError?.(...args);
|
|
426
|
+
};
|
|
427
|
+
// Capture only what is not already the wrapper, so repeated calls reinstall the one filter instead
|
|
428
|
+
// of nesting a new closure over the previous one and stranding the pristine handlers.
|
|
429
|
+
const installConsoleFilters = () => {
|
|
430
|
+
if (console.warn !== filteredWarn)
|
|
431
|
+
originalWarn = console.warn;
|
|
432
|
+
if (console.error !== filteredError)
|
|
433
|
+
originalError = console.error;
|
|
434
|
+
console.warn = filteredWarn;
|
|
435
|
+
console.error = filteredError;
|
|
436
|
+
};
|
|
437
|
+
const restoreConsole = () => {
|
|
438
|
+
if (originalWarn && console.warn === filteredWarn)
|
|
439
|
+
console.warn = originalWarn;
|
|
440
|
+
if (originalError && console.error === filteredError)
|
|
441
|
+
console.error = originalError;
|
|
442
|
+
originalWarn = null;
|
|
443
|
+
originalError = null;
|
|
444
|
+
};
|
|
363
445
|
const setupQueryTest = (config) => {
|
|
364
446
|
const baseUrl = config?.baseUrl ?? 'https://api.test.com';
|
|
365
447
|
const name = config?.name ?? 'test';
|
|
366
448
|
const mockErrorHandler = config?.mockErrorHandler !== false;
|
|
367
|
-
|
|
368
|
-
const originalWarn = console.warn;
|
|
369
|
-
console.warn = (...args) => {
|
|
370
|
-
const message = args[0];
|
|
371
|
-
if (typeof message === 'string' && message.includes('auto-refresh')) {
|
|
372
|
-
return; // Suppress auto-refresh warnings
|
|
373
|
-
}
|
|
374
|
-
originalWarn(...args);
|
|
375
|
-
};
|
|
376
|
-
// Suppress console.error for expected error scenarios during tests
|
|
377
|
-
const originalError = console.error;
|
|
378
|
-
console.error = (...args) => {
|
|
379
|
-
const message = args[0];
|
|
380
|
-
// Suppress HttpErrorResponse logs
|
|
381
|
-
if (message && typeof message === 'object' && 'name' in message && message.name === 'HttpErrorResponse') {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
// Suppress bearer token decryption errors
|
|
385
|
-
if (typeof message === 'string' && message.includes('Failed to decrypt bearer token')) {
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
// Suppress token extraction errors
|
|
389
|
-
if (typeof message === 'string' && message.includes('Failed to extract tokens from')) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
originalError(...args);
|
|
393
|
-
};
|
|
449
|
+
installConsoleFilters();
|
|
394
450
|
const providers = [
|
|
395
451
|
provideHttpClient(),
|
|
396
452
|
provideHttpClientTesting(),
|
|
@@ -404,7 +460,7 @@ const setupQueryTest = (config) => {
|
|
|
404
460
|
}
|
|
405
461
|
TestBed.configureTestingModule({ providers });
|
|
406
462
|
const queryClientRef = createQueryClient({ baseUrl, name });
|
|
407
|
-
|
|
463
|
+
return TestBed.runInInjectionContext(() => {
|
|
408
464
|
const { inject } = queryClientRef;
|
|
409
465
|
const queryClient = inject();
|
|
410
466
|
if (!queryClient) {
|
|
@@ -422,16 +478,9 @@ const setupQueryTest = (config) => {
|
|
|
422
478
|
createPut: createPutQuery(queryClientRef),
|
|
423
479
|
createPatch: createPatchQuery(queryClientRef),
|
|
424
480
|
createDelete: createDeleteQuery(queryClientRef),
|
|
481
|
+
restoreConsole,
|
|
425
482
|
};
|
|
426
483
|
});
|
|
427
|
-
// Restore console.warn and console.error when tests are done
|
|
428
|
-
const restoreConsole = () => {
|
|
429
|
-
console.warn = originalWarn;
|
|
430
|
-
console.error = originalError;
|
|
431
|
-
};
|
|
432
|
-
// Store the restore function for cleanup
|
|
433
|
-
setup._restoreConsole = restoreConsole;
|
|
434
|
-
return setup;
|
|
435
484
|
};
|
|
436
485
|
|
|
437
486
|
/**
|
|
@@ -440,33 +489,70 @@ const setupQueryTest = (config) => {
|
|
|
440
489
|
*
|
|
441
490
|
* Nothing here connects on its own: the client's own `connect()` only flips
|
|
442
491
|
* {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
|
|
443
|
-
* the `connect` listener. That split is what makes "joined a room while disconnected, and
|
|
444
|
-
*
|
|
492
|
+
* the `connect` listener. That split is what makes "joined a room while disconnected, and it was
|
|
493
|
+
* re-joined after a reconnect" testable.
|
|
445
494
|
*/
|
|
446
495
|
const createWebSocketTestDouble = () => {
|
|
447
496
|
const sent = [];
|
|
497
|
+
const delivered = [];
|
|
498
|
+
const buffered = [];
|
|
448
499
|
const listeners = new Map();
|
|
449
500
|
const anyListeners = [];
|
|
501
|
+
const outgoingListeners = [];
|
|
450
502
|
let connection = null;
|
|
503
|
+
let withCredentials = null;
|
|
451
504
|
let connectRequested = false;
|
|
452
505
|
let disconnected = false;
|
|
506
|
+
let connected = false;
|
|
507
|
+
let pingExpired = false;
|
|
508
|
+
let recovered = false;
|
|
509
|
+
const deliver = (message) => {
|
|
510
|
+
for (const listener of outgoingListeners)
|
|
511
|
+
listener(message.event, message.data);
|
|
512
|
+
delivered.push(message);
|
|
513
|
+
};
|
|
453
514
|
const socket = {
|
|
454
515
|
connect: () => void (connectRequested = true),
|
|
455
516
|
disconnect: () => void (disconnected = true),
|
|
456
|
-
emit: (event, data) =>
|
|
517
|
+
emit: (event, data) => {
|
|
518
|
+
sent.push({ event, data });
|
|
519
|
+
if (connected && !pingExpired)
|
|
520
|
+
deliver({ event, data });
|
|
521
|
+
else
|
|
522
|
+
buffered.push({ event, data });
|
|
523
|
+
},
|
|
457
524
|
on: (event, listener) => void listeners.set(event, listener),
|
|
458
525
|
onAny: (listener) => void anyListeners.push(listener),
|
|
526
|
+
onAnyOutgoing: (listener) => void outgoingListeners.push(listener),
|
|
527
|
+
get recovered() {
|
|
528
|
+
return recovered;
|
|
529
|
+
},
|
|
459
530
|
};
|
|
460
531
|
return {
|
|
461
532
|
io: (url, options) => {
|
|
462
533
|
connection = { url, transports: options.transports };
|
|
534
|
+
withCredentials = options.withCredentials;
|
|
463
535
|
return socket;
|
|
464
536
|
},
|
|
465
537
|
connection: () => connection,
|
|
538
|
+
withCredentials: () => withCredentials,
|
|
466
539
|
sent: () => [...sent],
|
|
540
|
+
delivered: () => [...delivered],
|
|
467
541
|
state: () => ({ connectRequested, disconnected }),
|
|
468
|
-
serverConnect: () =>
|
|
469
|
-
|
|
542
|
+
serverConnect: (options) => {
|
|
543
|
+
recovered = options?.recovered ?? false;
|
|
544
|
+
connected = true;
|
|
545
|
+
pingExpired = false;
|
|
546
|
+
for (const message of buffered.splice(0))
|
|
547
|
+
deliver(message);
|
|
548
|
+
listeners.get('connect')?.();
|
|
549
|
+
},
|
|
550
|
+
serverDisconnect: () => {
|
|
551
|
+
connected = false;
|
|
552
|
+
pingExpired = false;
|
|
553
|
+
listeners.get('disconnect')?.();
|
|
554
|
+
},
|
|
555
|
+
serverPingExpire: () => void (pingExpired = true),
|
|
470
556
|
serverSend: (message) => {
|
|
471
557
|
const frame = JSON.stringify(message);
|
|
472
558
|
for (const listener of anyListeners)
|