@editframe/elements 0.18.26-beta.0 → 0.19.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +10 -0
  2. package/dist/elements/EFMedia/AssetMediaEngine.js +13 -1
  3. package/dist/elements/EFMedia/BaseMediaEngine.js +1 -5
  4. package/dist/elements/EFMedia/JitMediaEngine.d.ts +10 -0
  5. package/dist/elements/EFMedia/JitMediaEngine.js +12 -0
  6. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +16 -12
  7. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.d.ts +1 -1
  8. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +0 -4
  9. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.d.ts +1 -1
  10. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +0 -4
  11. package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +1 -1
  12. package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +3 -2
  13. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +16 -12
  14. package/dist/elements/EFMedia.d.ts +2 -3
  15. package/dist/elements/EFMedia.js +0 -4
  16. package/dist/elements/EFTemporal.d.ts +9 -6
  17. package/dist/elements/EFTemporal.js +15 -12
  18. package/dist/elements/EFTimegroup.browsertest.d.ts +26 -0
  19. package/dist/elements/EFTimegroup.d.ts +12 -9
  20. package/dist/elements/EFTimegroup.js +114 -65
  21. package/dist/elements/EFVideo.d.ts +5 -1
  22. package/dist/elements/EFVideo.js +16 -8
  23. package/dist/elements/EFWaveform.js +2 -3
  24. package/dist/elements/FetchContext.browsertest.d.ts +0 -0
  25. package/dist/elements/FetchMixin.js +14 -9
  26. package/dist/elements/TimegroupController.js +2 -1
  27. package/dist/elements/updateAnimations.browsertest.d.ts +0 -0
  28. package/dist/elements/updateAnimations.d.ts +19 -9
  29. package/dist/elements/updateAnimations.js +64 -25
  30. package/dist/gui/ContextMixin.js +34 -27
  31. package/dist/gui/EFConfiguration.d.ts +1 -1
  32. package/dist/gui/EFConfiguration.js +1 -0
  33. package/dist/gui/EFFilmstrip.d.ts +1 -0
  34. package/dist/gui/EFFilmstrip.js +12 -14
  35. package/dist/gui/TWMixin.js +1 -1
  36. package/dist/style.css +1 -1
  37. package/dist/transcoding/cache/URLTokenDeduplicator.d.ts +38 -0
  38. package/dist/transcoding/cache/URLTokenDeduplicator.js +66 -0
  39. package/dist/transcoding/cache/URLTokenDeduplicator.test.d.ts +1 -0
  40. package/dist/transcoding/types/index.d.ts +10 -0
  41. package/package.json +2 -2
  42. package/src/elements/EFMedia/AssetMediaEngine.ts +16 -2
  43. package/src/elements/EFMedia/BaseMediaEngine.ts +0 -6
  44. package/src/elements/EFMedia/JitMediaEngine.ts +14 -0
  45. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.browsertest.ts +0 -1
  46. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +11 -4
  47. package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +0 -4
  48. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +4 -1
  49. package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +0 -5
  50. package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +2 -2
  51. package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +7 -3
  52. package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +11 -4
  53. package/src/elements/EFMedia.browsertest.ts +13 -4
  54. package/src/elements/EFMedia.ts +6 -10
  55. package/src/elements/EFTemporal.ts +21 -26
  56. package/src/elements/EFTimegroup.browsertest.ts +186 -2
  57. package/src/elements/EFTimegroup.ts +190 -94
  58. package/src/elements/EFVideo.browsertest.ts +53 -132
  59. package/src/elements/EFVideo.ts +26 -13
  60. package/src/elements/EFWaveform.ts +2 -3
  61. package/src/elements/FetchContext.browsertest.ts +396 -0
  62. package/src/elements/FetchMixin.ts +25 -8
  63. package/src/elements/TimegroupController.ts +2 -1
  64. package/src/elements/updateAnimations.browsertest.ts +559 -0
  65. package/src/elements/updateAnimations.ts +113 -50
  66. package/src/gui/ContextMixin.browsertest.ts +4 -9
  67. package/src/gui/ContextMixin.ts +52 -33
  68. package/src/gui/EFConfiguration.ts +1 -1
  69. package/src/gui/EFFilmstrip.ts +15 -18
  70. package/src/transcoding/cache/URLTokenDeduplicator.test.ts +182 -0
  71. package/src/transcoding/cache/URLTokenDeduplicator.ts +101 -0
  72. package/src/transcoding/types/index.ts +11 -0
  73. package/test/EFVideo.framegen.browsertest.ts +1 -1
  74. package/test/setup.ts +2 -0
  75. package/types.json +1 -1
@@ -1,94 +1,157 @@
1
- import { isEFTemporal } from "./EFTemporal.ts";
2
- import type { EFTimegroup } from "./EFTimegroup.ts";
3
-
4
- export const updateAnimations = (
5
- element: HTMLElement & {
6
- currentTimeMs: number;
7
- durationMs: number;
8
- rootTimegroup?: EFTimegroup;
9
- parentTimegroup?: EFTimegroup;
10
- startTimeMs: number;
11
- endTimeMs: number;
12
- },
13
- ) => {
14
- element.style.setProperty(
15
- "--ef-progress",
16
- `${Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs)) * 100}%`,
17
- );
1
+ import {
2
+ deepGetTemporalElements,
3
+ isEFTemporal,
4
+ type TemporalMixinInterface,
5
+ } from "./EFTemporal.ts";
6
+
7
+ // All animatable elements are temporal elements with HTMLElement interface
8
+ export type AnimatableElement = TemporalMixinInterface & HTMLElement;
9
+
10
+ // Constants
11
+ const ANIMATION_PRECISION_OFFSET = Number.EPSILON;
12
+ const DEFAULT_ANIMATION_ITERATIONS = 1;
13
+ const PROGRESS_PROPERTY = "--ef-progress";
14
+ const DURATION_PROPERTY = "--ef-duration";
15
+ const TRANSITION_DURATION_PROPERTY = "--ef-transition-duration";
16
+ const TRANSITION_OUT_START_PROPERTY = "--ef-transition-out-start";
17
+ const TIMEGROUP_TAGNAME = "ef-timegroup";
18
+
19
+ /**
20
+ * Represents the temporal state of an element relative to the timeline
21
+ */
22
+ interface TemporalState {
23
+ progress: number;
24
+ isVisible: boolean;
25
+ timelineTimeMs: number;
26
+ }
27
+
28
+ /**
29
+ * Evaluates what the element's state should be based on the timeline
30
+ */
31
+ export const evaluateTemporalState = (
32
+ element: AnimatableElement,
33
+ ): TemporalState => {
34
+ // Get timeline time from root timegroup, or use element's own time if it IS a timegroup
18
35
  const timelineTimeMs = (element.rootTimegroup ?? element).currentTimeMs;
19
- if (
20
- element.startTimeMs > timelineTimeMs ||
21
- element.endTimeMs < timelineTimeMs
22
- ) {
23
- element.style.display = "none";
36
+
37
+ const progress =
38
+ element.durationMs <= 0
39
+ ? 1
40
+ : Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
41
+
42
+ const isVisible =
43
+ element.startTimeMs <= timelineTimeMs && element.endTimeMs > timelineTimeMs;
44
+
45
+ return { progress, isVisible, timelineTimeMs };
46
+ };
47
+
48
+ /**
49
+ * Updates the visual state (CSS + display) to match temporal state
50
+ */
51
+ const updateVisualState = (
52
+ element: AnimatableElement,
53
+ state: TemporalState,
54
+ ): void => {
55
+ // Always set progress (needed for many use cases)
56
+ element.style.setProperty(PROGRESS_PROPERTY, `${state.progress * 100}%`);
57
+
58
+ // Handle visibility
59
+ if (!state.isVisible) {
60
+ if (element.style.display !== "none") {
61
+ element.style.display = "none";
62
+ }
24
63
  return;
25
64
  }
26
- element.style.display = "";
27
65
 
28
- // Check if getAnimations is available (Web Animations API - not available in Node.js)
29
- if (typeof element.getAnimations !== "function") {
30
- return;
66
+ if (element.style.display === "none") {
67
+ element.style.display = "";
31
68
  }
32
69
 
33
- const animations = element.getAnimations({ subtree: true });
34
- element.style.setProperty("--ef-duration", `${element.durationMs}ms`);
70
+ // Set other CSS properties for visible elements only
71
+ element.style.setProperty(DURATION_PROPERTY, `${element.durationMs}ms`);
35
72
  element.style.setProperty(
36
- "--ef-transition-duration",
73
+ TRANSITION_DURATION_PROPERTY,
37
74
  `${element.parentTimegroup?.overlapMs ?? 0}ms`,
38
75
  );
39
76
  element.style.setProperty(
40
- "--ef-transition-out-start",
77
+ TRANSITION_OUT_START_PROPERTY,
41
78
  `${element.durationMs - (element.parentTimegroup?.overlapMs ?? 0)}ms`,
42
79
  );
80
+ };
81
+
82
+ /**
83
+ * Coordinates animations to match timeline
84
+ */
85
+ const coordinateAnimations = (element: AnimatableElement): void => {
86
+ const animations = element.getAnimations({ subtree: true });
43
87
 
44
88
  for (const animation of animations) {
45
89
  if (animation.playState === "running") {
46
90
  animation.pause();
47
91
  }
92
+
48
93
  const effect = animation.effect;
49
94
  if (!(effect && effect instanceof KeyframeEffect)) {
50
95
  continue;
51
96
  }
97
+
52
98
  const target = effect.target;
53
- // TODO: better generalize work avoidance for temporal elements
54
- if (!target) {
99
+ if (!target || target.closest(TIMEGROUP_TAGNAME) !== element) {
55
100
  continue;
56
101
  }
57
- if (target.closest("ef-timegroup") !== element) {
102
+
103
+ const timeTarget = isEFTemporal(target)
104
+ ? target
105
+ : target.closest(TIMEGROUP_TAGNAME);
106
+ if (!timeTarget) {
58
107
  continue;
59
108
  }
60
109
 
61
110
  const timing = effect.getTiming();
62
- const duration = Number(timing.duration) ?? 0;
63
- const delay = Number(timing.delay) ?? 0;
64
- const iterations = Number(timing.iterations) ?? 1;
111
+ const duration = Number(timing.duration) || 0;
112
+ const delay = Number(timing.delay) || 0;
113
+ const iterations =
114
+ Number(timing.iterations) || DEFAULT_ANIMATION_ITERATIONS;
65
115
 
66
- const timeTarget = isEFTemporal(target)
67
- ? target
68
- : target.closest("ef-timegroup");
69
- if (!timeTarget) {
116
+ if (duration <= 0) {
117
+ animation.currentTime = 0;
70
118
  continue;
71
119
  }
72
120
 
73
- const currentTime = timeTarget.ownCurrentTimeMs;
121
+ // All timegroups are temporal, so always use ownCurrentTimeMs for local time coordination
122
+ const currentTime = timeTarget.ownCurrentTimeMs ?? 0;
74
123
 
75
- // Handle delay - don't start animation until delay is complete
76
124
  if (currentTime < delay) {
77
125
  animation.currentTime = 0;
78
126
  continue;
79
127
  }
80
128
 
81
- const currentIteration = Math.floor((currentTime - delay) / duration);
82
- const currentIterationTime = (currentTime - delay) % duration;
129
+ const adjustedTime = currentTime - delay;
130
+ const currentIteration = Math.floor(adjustedTime / duration);
131
+ const currentIterationTime = adjustedTime % duration;
83
132
 
84
133
  if (currentIteration >= iterations) {
85
- // Stop just before the end to prevent DOM removal
86
- animation.currentTime = duration - 0.01;
87
- continue;
134
+ animation.currentTime = duration - ANIMATION_PRECISION_OFFSET;
135
+ } else {
136
+ animation.currentTime =
137
+ Math.min(currentIterationTime, duration - ANIMATION_PRECISION_OFFSET) +
138
+ delay;
88
139
  }
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Main function: synchronizes DOM element with timeline
145
+ */
146
+ export const updateAnimations = (element: AnimatableElement): void => {
147
+ const temporalState = evaluateTemporalState(element);
148
+ deepGetTemporalElements(element).forEach((temporalElement) => {
149
+ const temporalState = evaluateTemporalState(temporalElement);
150
+ updateVisualState(temporalElement, temporalState);
151
+ });
152
+ updateVisualState(element, temporalState);
89
153
 
90
- // Ensure we never reach exactly duration
91
- animation.currentTime =
92
- Math.min(currentIterationTime, duration - 0.01) + delay;
154
+ if (temporalState.isVisible) {
155
+ coordinateAnimations(element);
93
156
  }
94
157
  };
@@ -269,7 +269,7 @@ describe("ContextMixin", () => {
269
269
  expect(mockFetch).toHaveBeenCalledTimes(7); // 3 signing + 4 media requests (URL2 reused token)
270
270
  }, 1000);
271
271
 
272
- test("should clear token cache on component disconnect", async () => {
272
+ test("should reuse global token cache across component disconnections", async () => {
273
273
  const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now
274
274
  const token1 = createJWTToken(futureTime);
275
275
 
@@ -296,13 +296,7 @@ describe("ContextMixin", () => {
296
296
  element.signingURL = "https://test.com/api/v1/url-token";
297
297
  document.body.appendChild(element);
298
298
 
299
- const token2 = createJWTToken(futureTime);
300
-
301
- mockFetch.mockResolvedValueOnce({
302
- ok: true,
303
- json: () => Promise.resolve({ token: token2 }),
304
- });
305
-
299
+ // No need to mock additional token request - should reuse from global cache
306
300
  mockFetch.mockResolvedValueOnce({
307
301
  ok: true,
308
302
  text: () => Promise.resolve("success2"),
@@ -310,7 +304,8 @@ describe("ContextMixin", () => {
310
304
 
311
305
  await element.fetch("https://example.com/media.mp4");
312
306
 
313
- expect(mockFetch).toHaveBeenCalledTimes(4); // 2 signing + 2 media requests
307
+ // With global caching: 1 signing + 2 media requests (token is reused)
308
+ expect(mockFetch).toHaveBeenCalledTimes(3);
314
309
  }, 1000);
315
310
 
316
311
  test("should use single token for multiple transcode segments with same source URL", async () => {
@@ -1,7 +1,9 @@
1
1
  import { consume, createContext, provide } from "@lit/context";
2
2
  import type { LitElement } from "lit";
3
3
  import { property, state } from "lit/decorators.js";
4
+ import { EF_RENDERING } from "../EF_RENDERING.ts";
4
5
  import type { EFTimegroup } from "../elements/EFTimegroup.js";
6
+ import { globalURLTokenDeduplicator } from "../transcoding/cache/URLTokenDeduplicator.js";
5
7
  import {
6
8
  type EFConfiguration,
7
9
  efConfigurationContext,
@@ -89,32 +91,33 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
89
91
  "Content-Type": "application/json",
90
92
  });
91
93
 
92
- if (this.signingURL) {
93
- const now = Date.now();
94
+ if (!EF_RENDERING() && this.signingURL) {
94
95
  const { cacheKey, signingPayload } = this.#getTokenCacheKey(url);
95
- const tokenExpiration = this.#URLTokenExpirations[cacheKey] || 0;
96
-
97
- // Check if we need to fetch a new token (no token exists or token is expired)
98
- if (!this.#URLTokens[cacheKey] || now >= tokenExpiration) {
99
- this.#URLTokens[cacheKey] = fetch(this.signingURL, {
100
- method: "POST",
101
- body: JSON.stringify(signingPayload),
102
- }).then(async (response) => {
103
- if (response.ok) {
104
- const tokenData = await response.json();
105
- const token = tokenData.token;
106
- // Parse and store the token's actual expiration time
107
- this.#URLTokenExpirations[cacheKey] =
108
- this.#parseTokenExpiration(token);
109
- return token;
110
- }
111
- throw new Error(
112
- `Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
113
- );
114
- });
115
- }
116
96
 
117
- const urlToken = await this.#URLTokens[cacheKey];
97
+ // Use global token deduplicator to share tokens across all context providers
98
+ const urlToken = await globalURLTokenDeduplicator.getToken(
99
+ cacheKey,
100
+ async () => {
101
+ try {
102
+ const response = await fetch(this.signingURL, {
103
+ method: "POST",
104
+ body: JSON.stringify(signingPayload),
105
+ });
106
+
107
+ if (response.ok) {
108
+ const tokenData = await response.json();
109
+ return tokenData.token;
110
+ }
111
+ throw new Error(
112
+ `Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
113
+ );
114
+ } catch (error) {
115
+ console.error("ContextMixin urlToken fetch error", url, error);
116
+ throw error;
117
+ }
118
+ },
119
+ (token: string) => this.#parseTokenExpiration(token),
120
+ );
118
121
 
119
122
  Object.assign(init.headers, {
120
123
  authorization: `Bearer ${urlToken}`,
@@ -123,11 +126,21 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
123
126
  init.credentials = "include";
124
127
  }
125
128
 
126
- return fetch(url, init);
129
+ try {
130
+ return fetch(url, init);
131
+ } catch (error) {
132
+ console.error(
133
+ "ContextMixin fetch error",
134
+ url,
135
+ error,
136
+ window.location.href,
137
+ );
138
+ throw error;
139
+ }
127
140
  };
128
141
 
129
- #URLTokens: Record<string, Promise<string>> = {};
130
- #URLTokenExpirations: Record<string, number> = {};
142
+ // Note: URL token caching is now handled globally via URLTokenDeduplicator
143
+ // Keeping these for any potential backwards compatibility, but they're no longer used
131
144
 
132
145
  /**
133
146
  * Generate a cache key for URL token based on signing strategy
@@ -236,7 +249,7 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
236
249
  rendering = false;
237
250
 
238
251
  @state()
239
- currentTimeMs = 0;
252
+ currentTimeMs = Number.NaN;
240
253
 
241
254
  #FPS = 30;
242
255
  #MS_PER_FRAME = 1000 / this.#FPS;
@@ -306,9 +319,8 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
306
319
  super.disconnectedCallback();
307
320
  this.#timegroupObserver.disconnect();
308
321
  this.stopPlayback();
309
- // Clear token cache on disconnect to prevent stale tokens
310
- this.#URLTokens = {};
311
- this.#URLTokenExpirations = {};
322
+ // Note: Global token cache is shared across all context providers
323
+ // No need to clear per-instance cache on disconnect
312
324
  }
313
325
 
314
326
  update(changedProperties: Map<string | number | symbol, unknown>) {
@@ -319,10 +331,17 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
319
331
  this.stopPlayback();
320
332
  }
321
333
  }
322
- if (changedProperties.has("currentTimeMs") && this.targetTimegroup) {
334
+ if (
335
+ changedProperties.has("currentTimeMs") &&
336
+ this.targetTimegroup &&
337
+ !Number.isNaN(this.currentTimeMs)
338
+ ) {
323
339
  if (this.targetTimegroup.currentTimeMs !== this.currentTimeMs) {
324
- this.targetTimegroup.currentTimeMs = this.currentTimeMs;
325
340
  if (this.isConnected) {
341
+ if (this.targetTimegroup.currentTimeMs === this.currentTimeMs) {
342
+ return;
343
+ }
344
+ this.targetTimegroup.currentTimeMs = this.currentTimeMs;
326
345
  this.dispatchEvent(
327
346
  new CustomEvent("timeupdate", {
328
347
  detail: {
@@ -23,7 +23,7 @@ export class EFConfiguration extends LitElement {
23
23
  apiHost?: string;
24
24
 
25
25
  @property({ type: String, attribute: "signing-url" })
26
- signingURL?: string;
26
+ signingURL = "/@ef-sign-url";
27
27
 
28
28
  @property({ type: String, attribute: "media-engine" })
29
29
  mediaEngine?: "cloud" | "local" = "cloud";
@@ -633,15 +633,7 @@ export class EFFilmstrip extends TWMixin(LitElement) {
633
633
  if (!this.scrubbing) {
634
634
  return;
635
635
  }
636
- const gutter = this.shadowRoot?.querySelector("#gutter");
637
- if (!gutter) {
638
- return;
639
- }
640
- const rect = gutter.getBoundingClientRect();
641
- if (this.targetTimegroup) {
642
- const layerX = e.pageX - rect.left + gutter.scrollLeft;
643
- this.targetTimegroup.currentTimeMs = layerX / this.pixelsPerMs;
644
- }
636
+ this.applyScrub(e);
645
637
  }
646
638
 
647
639
  @eventOptions({ capture: false })
@@ -651,15 +643,7 @@ export class EFFilmstrip extends TWMixin(LitElement) {
651
643
  // Running scrub in the current microtask doesn't
652
644
  // result in an actual update. Not sure why.
653
645
  queueMicrotask(() => {
654
- const gutter = this.shadowRoot?.querySelector("#gutter");
655
- if (!gutter) {
656
- return;
657
- }
658
- const rect = gutter.getBoundingClientRect();
659
- if (this.targetTimegroup) {
660
- const layerX = e.pageX - rect.left + gutter.scrollLeft;
661
- this.targetTimegroup.currentTimeMs = layerX / this.pixelsPerMs;
662
- }
646
+ this.applyScrub(e);
663
647
  });
664
648
  addEventListener(
665
649
  "mouseup",
@@ -670,6 +654,19 @@ export class EFFilmstrip extends TWMixin(LitElement) {
670
654
  );
671
655
  }
672
656
 
657
+ applyScrub(e: MouseEvent) {
658
+ const gutter = this.shadowRoot?.querySelector("#gutter");
659
+ if (!gutter) {
660
+ return;
661
+ }
662
+ const rect = gutter.getBoundingClientRect();
663
+ if (this.targetTimegroup) {
664
+ const layerX = e.pageX - rect.left + gutter.scrollLeft;
665
+ const scrubTimeMs = layerX / this.pixelsPerMs;
666
+ this.targetTimegroup.currentTimeMs = scrubTimeMs;
667
+ }
668
+ }
669
+
673
670
  @eventOptions({ passive: false })
674
671
  scrollScrub(e: WheelEvent) {
675
672
  if (this.targetTimegroup && this.gutter && !this.playing) {
@@ -0,0 +1,182 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { URLTokenDeduplicator } from "./URLTokenDeduplicator.js";
3
+
4
+ describe("URLTokenDeduplicator", () => {
5
+ let deduplicator: URLTokenDeduplicator;
6
+
7
+ beforeEach(() => {
8
+ deduplicator = new URLTokenDeduplicator();
9
+ vi.useFakeTimers();
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ it("should deduplicate concurrent requests for same cache key", async () => {
17
+ const mockTokenFactory = vi.fn().mockResolvedValue("test-token");
18
+ const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
19
+
20
+ // Make 3 concurrent requests for the same cache key
21
+ const promises = [
22
+ deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
23
+ deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
24
+ deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
25
+ ];
26
+
27
+ const results = await Promise.all(promises);
28
+
29
+ // All should return the same token
30
+ expect(results).toEqual(["test-token", "test-token", "test-token"]);
31
+
32
+ // Factory should only be called once due to deduplication
33
+ expect(mockTokenFactory).toHaveBeenCalledTimes(1);
34
+ expect(mockParseExpiration).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it("should use separate tokens for different cache keys", async () => {
38
+ const mockTokenFactory1 = vi.fn().mockResolvedValue("token1");
39
+ const mockTokenFactory2 = vi.fn().mockResolvedValue("token2");
40
+ const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
41
+
42
+ const [token1, token2] = await Promise.all([
43
+ deduplicator.getToken("key1", mockTokenFactory1, mockParseExpiration),
44
+ deduplicator.getToken("key2", mockTokenFactory2, mockParseExpiration),
45
+ ]);
46
+
47
+ expect(token1).toBe("token1");
48
+ expect(token2).toBe("token2");
49
+ expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
50
+ expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it("should reuse cached tokens that haven't expired", async () => {
54
+ const futureTime = Date.now() + 60000;
55
+ const mockTokenFactory = vi.fn().mockResolvedValue("cached-token");
56
+ const mockParseExpiration = vi.fn().mockReturnValue(futureTime);
57
+
58
+ // First request
59
+ const token1 = await deduplicator.getToken(
60
+ "key1",
61
+ mockTokenFactory,
62
+ mockParseExpiration,
63
+ );
64
+
65
+ // Second request for same key should reuse cached token
66
+ const token2 = await deduplicator.getToken(
67
+ "key1",
68
+ vi.fn(),
69
+ mockParseExpiration,
70
+ );
71
+
72
+ expect(token1).toBe("cached-token");
73
+ expect(token2).toBe("cached-token");
74
+ expect(mockTokenFactory).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("should fetch new token when cached token is expired", async () => {
78
+ const pastTime = Date.now() - 1000; // Expired 1 second ago
79
+ const futureTime = Date.now() + 60000; // New token expires in 1 minute
80
+
81
+ const mockTokenFactory1 = vi.fn().mockResolvedValue("expired-token");
82
+ const mockTokenFactory2 = vi.fn().mockResolvedValue("fresh-token");
83
+ const mockParseExpiration1 = vi.fn().mockReturnValue(pastTime);
84
+ const mockParseExpiration2 = vi.fn().mockReturnValue(futureTime);
85
+
86
+ // First request with expired token
87
+ await deduplicator.getToken(
88
+ "key1",
89
+ mockTokenFactory1,
90
+ mockParseExpiration1,
91
+ );
92
+
93
+ // Advance time to simulate token expiration
94
+ vi.advanceTimersByTime(2000);
95
+
96
+ // Second request should fetch new token due to expiration
97
+ const token2 = await deduplicator.getToken(
98
+ "key1",
99
+ mockTokenFactory2,
100
+ mockParseExpiration2,
101
+ );
102
+
103
+ expect(token2).toBe("fresh-token");
104
+ expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
105
+ expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("should handle token factory errors by removing from cache", async () => {
109
+ const mockTokenFactory1 = vi
110
+ .fn()
111
+ .mockRejectedValue(new Error("Network error"));
112
+ const mockTokenFactory2 = vi.fn().mockResolvedValue("retry-token");
113
+ const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
114
+
115
+ // First request fails
116
+ await expect(
117
+ deduplicator.getToken("key1", mockTokenFactory1, mockParseExpiration),
118
+ ).rejects.toThrow("Network error");
119
+
120
+ // Retry should work and not be blocked by failed request
121
+ const token = await deduplicator.getToken(
122
+ "key1",
123
+ mockTokenFactory2,
124
+ mockParseExpiration,
125
+ );
126
+
127
+ expect(token).toBe("retry-token");
128
+ expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
129
+ expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it("should provide utility methods for cache management", () => {
133
+ const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
134
+
135
+ expect(deduplicator.getCachedCount()).toBe(0);
136
+ expect(deduplicator.getCachedKeys()).toEqual([]);
137
+ expect(deduplicator.hasValidToken("nonexistent")).toBe(false);
138
+
139
+ // Add a token to cache
140
+ deduplicator.getToken(
141
+ "key1",
142
+ vi.fn().mockResolvedValue("token"),
143
+ mockParseExpiration,
144
+ );
145
+
146
+ expect(deduplicator.getCachedCount()).toBe(1);
147
+ expect(deduplicator.getCachedKeys()).toEqual(["key1"]);
148
+ expect(deduplicator.hasValidToken("key1")).toBe(true);
149
+
150
+ // Clear cache
151
+ deduplicator.clear();
152
+ expect(deduplicator.getCachedCount()).toBe(0);
153
+ });
154
+
155
+ it("should cleanup expired tokens", async () => {
156
+ const pastTime = Date.now() - 1000;
157
+ const futureTime = Date.now() + 60000;
158
+
159
+ const mockParseExpiration1 = vi.fn().mockReturnValue(pastTime);
160
+ const mockParseExpiration2 = vi.fn().mockReturnValue(futureTime);
161
+
162
+ // Add expired and valid tokens
163
+ await deduplicator.getToken(
164
+ "expired",
165
+ vi.fn().mockResolvedValue("token1"),
166
+ mockParseExpiration1,
167
+ );
168
+ await deduplicator.getToken(
169
+ "valid",
170
+ vi.fn().mockResolvedValue("token2"),
171
+ mockParseExpiration2,
172
+ );
173
+
174
+ expect(deduplicator.getCachedCount()).toBe(2);
175
+
176
+ // Cleanup should remove expired tokens
177
+ deduplicator.cleanup();
178
+
179
+ expect(deduplicator.getCachedCount()).toBe(1);
180
+ expect(deduplicator.getCachedKeys()).toEqual(["valid"]);
181
+ });
182
+ });