@runtypelabs/persona 4.17.1 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1212 -14
- package/dist/context-mentions.d.ts +1212 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1987 -17
- package/dist/index.d.ts +1987 -17
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1212 -14
- package/dist/smart-dom-reader.d.ts +1212 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1331 -15
- package/dist/theme-editor-preview.d.ts +1331 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1215 -15
- package/dist/theme-editor.d.ts +1215 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +16 -4
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +1 -0
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/utils/tooltip.ts
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
1
|
import { PORTALED_OVERLAY_Z_INDEX } from "./constants";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_TOOLTIP_DELAY_MS,
|
|
4
|
+
DEFAULT_TOOLTIP_SKIP_DELAY_MS,
|
|
5
|
+
} from "./tooltip-timing";
|
|
6
|
+
|
|
7
|
+
export { DEFAULT_TOOLTIP_DELAY_MS, DEFAULT_TOOLTIP_SKIP_DELAY_MS };
|
|
2
8
|
|
|
3
9
|
const DEFAULT_GAP = 8;
|
|
4
10
|
const DEFAULT_VIEWPORT_PADDING = 8;
|
|
5
11
|
const MIN_ARROW_INSET = 10;
|
|
6
12
|
|
|
13
|
+
let configuredDelayMs = DEFAULT_TOOLTIP_DELAY_MS;
|
|
14
|
+
let configuredSkipDelayMs = DEFAULT_TOOLTIP_SKIP_DELAY_MS;
|
|
15
|
+
let skipDelayUntil = 0;
|
|
16
|
+
|
|
17
|
+
const clampMs = (value: number): number => Math.max(0, value);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set the widget-wide hover timing. Per-attach `delayMs` / `skipDelayMs`
|
|
21
|
+
* still win. Keyboard focus is always immediate.
|
|
22
|
+
*/
|
|
23
|
+
export function configureTooltipTiming(timing: {
|
|
24
|
+
delayMs?: number;
|
|
25
|
+
skipDelayMs?: number;
|
|
26
|
+
}): void {
|
|
27
|
+
if (timing.delayMs !== undefined) configuredDelayMs = clampMs(timing.delayMs);
|
|
28
|
+
if (timing.skipDelayMs !== undefined)
|
|
29
|
+
configuredSkipDelayMs = clampMs(timing.skipDelayMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Restore product defaults and clear the skip-delay window. */
|
|
33
|
+
export function resetTooltipTiming(timing?: {
|
|
34
|
+
delayMs?: number;
|
|
35
|
+
skipDelayMs?: number;
|
|
36
|
+
}): void {
|
|
37
|
+
configuredDelayMs = clampMs(timing?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS);
|
|
38
|
+
configuredSkipDelayMs = clampMs(
|
|
39
|
+
timing?.skipDelayMs ?? DEFAULT_TOOLTIP_SKIP_DELAY_MS
|
|
40
|
+
);
|
|
41
|
+
skipDelayUntil = 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isSkipDelayWarm = (): boolean => Date.now() < skipDelayUntil;
|
|
45
|
+
|
|
46
|
+
const markSkipDelay = (skipMs: number): void => {
|
|
47
|
+
skipDelayUntil = skipMs > 0 ? Date.now() + skipMs : 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
7
50
|
export interface TooltipOptions {
|
|
8
51
|
/** The control the tooltip describes and positions against. */
|
|
9
52
|
anchor: HTMLElement;
|
|
@@ -11,12 +54,27 @@ export interface TooltipOptions {
|
|
|
11
54
|
trigger?: HTMLElement;
|
|
12
55
|
/** Tooltip copy. A callback keeps live aria-label updates in sync. */
|
|
13
56
|
text: string | (() => string);
|
|
57
|
+
/**
|
|
58
|
+
* Muted chip after the label, e.g. a keyboard shortcut. Empty renders
|
|
59
|
+
* nothing. A callback is re-read on every reposition, like `text`.
|
|
60
|
+
*/
|
|
61
|
+
hint?: string | (() => string);
|
|
14
62
|
/** Whether the visual tooltip may open. The anchor's accessible name is unaffected. */
|
|
15
63
|
enabled?: boolean;
|
|
16
64
|
/** Gap between the control and tooltip, in pixels. */
|
|
17
65
|
gap?: number;
|
|
18
66
|
/** Minimum distance from viewport edges, in pixels. */
|
|
19
67
|
viewportPadding?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Hover wait before the first tooltip. Falls back to the widget
|
|
70
|
+
* `tooltip.delayMs` (default 200). Keyboard focus ignores this.
|
|
71
|
+
*/
|
|
72
|
+
delayMs?: number;
|
|
73
|
+
/**
|
|
74
|
+
* After a tooltip closes, later hovers in this window skip `delayMs`.
|
|
75
|
+
* Falls back to the widget `tooltip.skipDelayMs` (default 300).
|
|
76
|
+
*/
|
|
77
|
+
skipDelayMs?: number;
|
|
20
78
|
}
|
|
21
79
|
|
|
22
80
|
export interface TooltipHandle {
|
|
@@ -41,6 +99,26 @@ const tooltipContainer = (anchor: HTMLElement): HTMLElement | ShadowRoot | null
|
|
|
41
99
|
const clamp = (value: number, min: number, max: number): number =>
|
|
42
100
|
Math.min(Math.max(value, min), Math.max(min, max));
|
|
43
101
|
|
|
102
|
+
// Theme variables live on the widget mount, which a body-portaled tooltip is
|
|
103
|
+
// not inside, so the anchor's resolved values are copied onto it at show time.
|
|
104
|
+
// A theme change lands on the next open.
|
|
105
|
+
const INHERITED_VARS = [
|
|
106
|
+
...[
|
|
107
|
+
"background",
|
|
108
|
+
"foreground",
|
|
109
|
+
"hint-fg",
|
|
110
|
+
"radius",
|
|
111
|
+
"font-size",
|
|
112
|
+
"padding",
|
|
113
|
+
"max-width",
|
|
114
|
+
"shadow",
|
|
115
|
+
"arrow-display",
|
|
116
|
+
].map((name) => `--persona-tooltip-${name}`),
|
|
117
|
+
// Inputs to the stylesheet's own var() fallback chains.
|
|
118
|
+
"--persona-font-family",
|
|
119
|
+
"--persona-radius-sm",
|
|
120
|
+
];
|
|
121
|
+
|
|
44
122
|
/**
|
|
45
123
|
* Attach a portaled, viewport-aware tooltip to an icon control.
|
|
46
124
|
*
|
|
@@ -55,6 +133,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
55
133
|
anchor,
|
|
56
134
|
trigger = anchor,
|
|
57
135
|
text,
|
|
136
|
+
hint,
|
|
58
137
|
enabled = true,
|
|
59
138
|
gap = DEFAULT_GAP,
|
|
60
139
|
viewportPadding = DEFAULT_VIEWPORT_PADDING,
|
|
@@ -65,20 +144,38 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
65
144
|
|
|
66
145
|
let tooltip: HTMLElement | null = null;
|
|
67
146
|
let label: HTMLElement | null = null;
|
|
147
|
+
let hintChip: HTMLElement | null = null;
|
|
68
148
|
let detachOpenListeners: (() => void) | null = null;
|
|
149
|
+
let openTimer: ReturnType<Window["setTimeout"]> | undefined;
|
|
69
150
|
let hovered = false;
|
|
70
151
|
let focused = false;
|
|
71
152
|
let destroyed = false;
|
|
72
153
|
|
|
73
154
|
const resolvedText = (): string =>
|
|
74
155
|
(typeof text === "function" ? text() : text).trim();
|
|
156
|
+
const resolvedHint = (): string =>
|
|
157
|
+
(typeof hint === "function" ? hint() : (hint ?? "")).trim();
|
|
158
|
+
const resolvedDelayMs = (): number =>
|
|
159
|
+
clampMs(options.delayMs ?? configuredDelayMs);
|
|
160
|
+
const resolvedSkipDelayMs = (): number =>
|
|
161
|
+
clampMs(options.skipDelayMs ?? configuredSkipDelayMs);
|
|
162
|
+
|
|
163
|
+
const clearOpenTimer = (): void => {
|
|
164
|
+
if (openTimer === undefined) return;
|
|
165
|
+
currentWindow().clearTimeout(openTimer);
|
|
166
|
+
openTimer = undefined;
|
|
167
|
+
};
|
|
75
168
|
|
|
76
169
|
const hide = (): void => {
|
|
170
|
+
clearOpenTimer();
|
|
171
|
+
const wasOpen = tooltip !== null;
|
|
77
172
|
detachOpenListeners?.();
|
|
78
173
|
detachOpenListeners = null;
|
|
79
174
|
tooltip?.remove();
|
|
80
175
|
tooltip = null;
|
|
81
176
|
label = null;
|
|
177
|
+
hintChip = null;
|
|
178
|
+
if (wasOpen) markSkipDelay(resolvedSkipDelayMs());
|
|
82
179
|
};
|
|
83
180
|
|
|
84
181
|
const reposition = (): void => {
|
|
@@ -138,11 +235,30 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
138
235
|
tooltip.dataset.state = "measuring";
|
|
139
236
|
tooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
|
|
140
237
|
|
|
238
|
+
const computed = ownerWindow.getComputedStyle?.(anchor);
|
|
239
|
+
if (computed) {
|
|
240
|
+
for (const name of INHERITED_VARS) {
|
|
241
|
+
const value = computed.getPropertyValue(name).trim();
|
|
242
|
+
if (value) tooltip.style.setProperty(name, value);
|
|
243
|
+
}
|
|
244
|
+
// Body portal inherits the host page font, not the widget's; the anchor's
|
|
245
|
+
// resolved family is the font the control actually renders in.
|
|
246
|
+
if (computed.fontFamily) tooltip.style.fontFamily = computed.fontFamily;
|
|
247
|
+
}
|
|
248
|
+
|
|
141
249
|
label = ownerDocument.createElement("span");
|
|
142
250
|
label.className = "persona-control-tooltip__label";
|
|
143
251
|
label.textContent = copy;
|
|
144
252
|
tooltip.appendChild(label);
|
|
145
253
|
|
|
254
|
+
const hintCopy = resolvedHint();
|
|
255
|
+
if (hintCopy) {
|
|
256
|
+
hintChip = ownerDocument.createElement("span");
|
|
257
|
+
hintChip.className = "persona-control-tooltip__hint";
|
|
258
|
+
hintChip.textContent = hintCopy;
|
|
259
|
+
tooltip.appendChild(hintChip);
|
|
260
|
+
}
|
|
261
|
+
|
|
146
262
|
const arrow = ownerDocument.createElement("span");
|
|
147
263
|
arrow.className = "persona-control-tooltip__arrow";
|
|
148
264
|
tooltip.appendChild(arrow);
|
|
@@ -157,6 +273,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
157
273
|
return;
|
|
158
274
|
}
|
|
159
275
|
if (label) label.textContent = resolvedText();
|
|
276
|
+
if (hintChip) hintChip.textContent = resolvedHint();
|
|
160
277
|
reposition();
|
|
161
278
|
};
|
|
162
279
|
ownerWindow.addEventListener("scroll", onReposition, true);
|
|
@@ -182,8 +299,21 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
182
299
|
};
|
|
183
300
|
};
|
|
184
301
|
|
|
302
|
+
const scheduleShow = (immediate: boolean): void => {
|
|
303
|
+
if (destroyed || tooltip) return;
|
|
304
|
+
clearOpenTimer();
|
|
305
|
+
if (immediate || resolvedDelayMs() <= 0 || isSkipDelayWarm()) {
|
|
306
|
+
show();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
openTimer = currentWindow().setTimeout(() => {
|
|
310
|
+
openTimer = undefined;
|
|
311
|
+
show();
|
|
312
|
+
}, resolvedDelayMs());
|
|
313
|
+
};
|
|
314
|
+
|
|
185
315
|
const syncVisibility = (): void => {
|
|
186
|
-
if (hovered || focused)
|
|
316
|
+
if (hovered || focused) scheduleShow(focused);
|
|
187
317
|
else hide();
|
|
188
318
|
};
|
|
189
319
|
const onMouseEnter = (): void => {
|
|
@@ -200,25 +330,36 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
200
330
|
syncVisibility();
|
|
201
331
|
};
|
|
202
332
|
const onFocus = (): void => {
|
|
203
|
-
|
|
333
|
+
// Only keyboard-visible focus opens the tooltip: a shell moving focus
|
|
334
|
+
// programmatically after a mouse interaction must not pop one.
|
|
335
|
+
try {
|
|
336
|
+
focused = anchor.matches(":focus-visible");
|
|
337
|
+
} catch {
|
|
338
|
+
focused = true;
|
|
339
|
+
}
|
|
204
340
|
syncVisibility();
|
|
205
341
|
};
|
|
206
342
|
const onBlur = (): void => {
|
|
207
343
|
focused = false;
|
|
208
344
|
syncVisibility();
|
|
209
345
|
};
|
|
210
|
-
|
|
211
|
-
|
|
346
|
+
// Activation dismisses the tooltip. The flags re-arm on the next mouseenter
|
|
347
|
+
// or keyboard-visible focus, which re-reads whatever label the control now has.
|
|
348
|
+
const dismiss = (): void => {
|
|
212
349
|
hovered = false;
|
|
213
350
|
focused = false;
|
|
214
351
|
hide();
|
|
215
352
|
};
|
|
353
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
354
|
+
if (event.key === "Escape") dismiss();
|
|
355
|
+
};
|
|
216
356
|
|
|
217
357
|
trigger.addEventListener("mouseenter", onMouseEnter);
|
|
218
358
|
trigger.addEventListener("mouseleave", onMouseLeave);
|
|
219
359
|
anchor.addEventListener("focus", onFocus);
|
|
220
360
|
anchor.addEventListener("blur", onBlur);
|
|
221
361
|
anchor.addEventListener("keydown", onKeyDown);
|
|
362
|
+
anchor.addEventListener("click", dismiss);
|
|
222
363
|
|
|
223
364
|
const handle: TooltipHandle = {
|
|
224
365
|
get isOpen() {
|
|
@@ -236,6 +377,7 @@ export function attachTooltip(options: TooltipOptions): TooltipHandle {
|
|
|
236
377
|
anchor.removeEventListener("focus", onFocus);
|
|
237
378
|
anchor.removeEventListener("blur", onBlur);
|
|
238
379
|
anchor.removeEventListener("keydown", onKeyDown);
|
|
380
|
+
anchor.removeEventListener("click", dismiss);
|
|
239
381
|
handles.delete(anchor);
|
|
240
382
|
},
|
|
241
383
|
};
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createVisitorStore,
|
|
7
|
+
visitorStoreKeys,
|
|
8
|
+
type VisitorStore,
|
|
9
|
+
type VisitorStoreChange,
|
|
10
|
+
} from "./visitor-store";
|
|
11
|
+
|
|
12
|
+
const CLIENT_TOKEN = "ct_live_abc123";
|
|
13
|
+
const PREFIX = "persona-";
|
|
14
|
+
|
|
15
|
+
interface LeaseShape {
|
|
16
|
+
ownerNonce?: unknown;
|
|
17
|
+
expiresAt?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface LockManagerLike {
|
|
21
|
+
request<T>(name: string, callback: () => Promise<T> | T): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type NavigatorWithLocks = Navigator & { locks?: LockManagerLike };
|
|
25
|
+
|
|
26
|
+
const sha256Hex = async (input: string): Promise<string> => {
|
|
27
|
+
const digest = await globalThis.crypto.subtle.digest(
|
|
28
|
+
"SHA-256",
|
|
29
|
+
new TextEncoder().encode(input)
|
|
30
|
+
);
|
|
31
|
+
return Array.from(new Uint8Array(digest))
|
|
32
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
33
|
+
.join("");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
37
|
+
/** Let the store's async `ready` chain and any queued microtasks settle. */
|
|
38
|
+
const flush = () => sleep(0);
|
|
39
|
+
|
|
40
|
+
const stores: VisitorStore[] = [];
|
|
41
|
+
const makeStore = (
|
|
42
|
+
clientToken = CLIENT_TOKEN,
|
|
43
|
+
keyPrefix = PREFIX,
|
|
44
|
+
persistDisabled = false
|
|
45
|
+
): VisitorStore => {
|
|
46
|
+
const store = createVisitorStore(clientToken, keyPrefix, persistDisabled);
|
|
47
|
+
stores.push(store);
|
|
48
|
+
return store;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Serializes callbacks per lock name, like a real single-mode Web Lock. */
|
|
52
|
+
const installFakeLocks = (): { requests: string[] } => {
|
|
53
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
54
|
+
const requests: string[] = [];
|
|
55
|
+
const manager: LockManagerLike = {
|
|
56
|
+
request<T>(name: string, callback: () => Promise<T> | T): Promise<T> {
|
|
57
|
+
requests.push(name);
|
|
58
|
+
const previous = chains.get(name) ?? Promise.resolve();
|
|
59
|
+
const run = previous.then(() => callback());
|
|
60
|
+
chains.set(
|
|
61
|
+
name,
|
|
62
|
+
run.then(
|
|
63
|
+
() => undefined,
|
|
64
|
+
() => undefined
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
return run;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
Object.defineProperty(globalThis.navigator, "locks", {
|
|
71
|
+
value: manager,
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
});
|
|
75
|
+
return { requests };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
describe("visitor store", () => {
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
for (const store of stores.splice(0)) store.destroy();
|
|
81
|
+
// lib.dom types `Navigator.locks` as required, so drop it reflectively.
|
|
82
|
+
Reflect.deleteProperty(globalThis.navigator, "locks");
|
|
83
|
+
window.localStorage.clear();
|
|
84
|
+
vi.restoreAllMocks();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("namespaces the storage key by key prefix and a sha-256 of the client token", async () => {
|
|
88
|
+
const hash = await sha256Hex(CLIENT_TOKEN);
|
|
89
|
+
const keys = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
90
|
+
|
|
91
|
+
expect(keys.storageKey).toBe(`persona-visitor:${hash}`);
|
|
92
|
+
expect(keys.leaseKey).toBe(`persona-visitor-init-lock:${hash}`);
|
|
93
|
+
expect(keys.storageKey).not.toContain(CLIENT_TOKEN);
|
|
94
|
+
|
|
95
|
+
const scoped = await visitorStoreKeys(CLIENT_TOKEN, "myapp-");
|
|
96
|
+
expect(scoped.storageKey).toBe(`myapp-visitor:${hash}`);
|
|
97
|
+
// The lock name also folds in the prefix, so two surfaces do not share it.
|
|
98
|
+
expect(scoped.lockName).not.toBe(keys.lockName);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("round-trips the raw token string under the namespaced key", async () => {
|
|
102
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
103
|
+
const store = makeStore();
|
|
104
|
+
|
|
105
|
+
await store.set("cvt_alpha");
|
|
106
|
+
|
|
107
|
+
expect(window.localStorage.getItem(storageKey)).toBe("cvt_alpha");
|
|
108
|
+
expect(await store.get()).toBe("cvt_alpha");
|
|
109
|
+
|
|
110
|
+
await store.clear();
|
|
111
|
+
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
|
112
|
+
expect(await store.get()).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("hydrates an existing credential silently at boot", async () => {
|
|
116
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
117
|
+
window.localStorage.setItem(storageKey, "cvt_seeded");
|
|
118
|
+
|
|
119
|
+
const store = makeStore();
|
|
120
|
+
const changes: VisitorStoreChange[] = [];
|
|
121
|
+
store.subscribe((change) => changes.push(change));
|
|
122
|
+
|
|
123
|
+
expect(await store.get()).toBe("cvt_seeded");
|
|
124
|
+
expect(store.revision()).toBe(0);
|
|
125
|
+
expect(changes).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("bumps the revision and notifies once on a local set", async () => {
|
|
129
|
+
const store = makeStore();
|
|
130
|
+
const changes: VisitorStoreChange[] = [];
|
|
131
|
+
store.subscribe((change) => changes.push(change));
|
|
132
|
+
|
|
133
|
+
await store.set("cvt_alpha");
|
|
134
|
+
|
|
135
|
+
expect(store.revision()).toBe(1);
|
|
136
|
+
expect(changes).toEqual([
|
|
137
|
+
{
|
|
138
|
+
previousToken: null,
|
|
139
|
+
token: "cvt_alpha",
|
|
140
|
+
source: "local",
|
|
141
|
+
revision: 1,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("treats a set of the same value as a no-op", async () => {
|
|
147
|
+
const store = makeStore();
|
|
148
|
+
await store.set("cvt_alpha");
|
|
149
|
+
|
|
150
|
+
const changes: VisitorStoreChange[] = [];
|
|
151
|
+
store.subscribe((change) => changes.push(change));
|
|
152
|
+
await store.set("cvt_alpha");
|
|
153
|
+
|
|
154
|
+
expect(store.revision()).toBe(1);
|
|
155
|
+
expect(changes).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adopts an external token replacement from a storage event", async () => {
|
|
159
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
160
|
+
const store = makeStore();
|
|
161
|
+
await store.set("cvt_alpha");
|
|
162
|
+
|
|
163
|
+
const changes: VisitorStoreChange[] = [];
|
|
164
|
+
store.subscribe((change) => changes.push(change));
|
|
165
|
+
|
|
166
|
+
// Another tab replaced the credential: value first, then the notification.
|
|
167
|
+
window.localStorage.setItem(storageKey, "cvt_beta");
|
|
168
|
+
window.dispatchEvent(
|
|
169
|
+
new StorageEvent("storage", {
|
|
170
|
+
key: storageKey,
|
|
171
|
+
oldValue: "cvt_alpha",
|
|
172
|
+
newValue: "cvt_beta",
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(changes).toEqual([
|
|
177
|
+
{
|
|
178
|
+
previousToken: "cvt_alpha",
|
|
179
|
+
token: "cvt_beta",
|
|
180
|
+
source: "external",
|
|
181
|
+
revision: 2,
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
expect(store.revision()).toBe(2);
|
|
185
|
+
expect(await store.get()).toBe("cvt_beta");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("observes null when another tab clears the whole store (key: null)", async () => {
|
|
189
|
+
const store = makeStore();
|
|
190
|
+
await store.set("cvt_alpha");
|
|
191
|
+
|
|
192
|
+
const changes: VisitorStoreChange[] = [];
|
|
193
|
+
store.subscribe((change) => changes.push(change));
|
|
194
|
+
|
|
195
|
+
window.localStorage.clear();
|
|
196
|
+
window.dispatchEvent(
|
|
197
|
+
new StorageEvent("storage", { key: null, oldValue: null, newValue: null })
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(changes).toEqual([
|
|
201
|
+
{
|
|
202
|
+
previousToken: "cvt_alpha",
|
|
203
|
+
token: null,
|
|
204
|
+
source: "external",
|
|
205
|
+
revision: 2,
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
expect(await store.get()).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("ignores storage events for unrelated keys", async () => {
|
|
212
|
+
const store = makeStore();
|
|
213
|
+
await store.set("cvt_alpha");
|
|
214
|
+
|
|
215
|
+
const changes: VisitorStoreChange[] = [];
|
|
216
|
+
store.subscribe((change) => changes.push(change));
|
|
217
|
+
|
|
218
|
+
window.dispatchEvent(
|
|
219
|
+
new StorageEvent("storage", {
|
|
220
|
+
key: "persona-plugin:home-screen:view",
|
|
221
|
+
oldValue: null,
|
|
222
|
+
newValue: "home",
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(changes).toEqual([]);
|
|
227
|
+
expect(store.revision()).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps the token in memory only when persistence is disabled", async () => {
|
|
231
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
232
|
+
const store = makeStore(CLIENT_TOKEN, PREFIX, true);
|
|
233
|
+
|
|
234
|
+
const changes: VisitorStoreChange[] = [];
|
|
235
|
+
store.subscribe((change) => changes.push(change));
|
|
236
|
+
|
|
237
|
+
await store.set("cvt_alpha");
|
|
238
|
+
|
|
239
|
+
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
|
240
|
+
expect(await store.get()).toBe("cvt_alpha");
|
|
241
|
+
expect(store.revision()).toBe(1);
|
|
242
|
+
expect(changes).toHaveLength(1);
|
|
243
|
+
expect(changes[0]?.source).toBe("local");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("degrades to memory when localStorage throws (private mode)", async () => {
|
|
247
|
+
const setItem = vi
|
|
248
|
+
.spyOn(Storage.prototype, "setItem")
|
|
249
|
+
.mockImplementation(() => {
|
|
250
|
+
throw new Error("blocked");
|
|
251
|
+
});
|
|
252
|
+
const getItem = vi
|
|
253
|
+
.spyOn(Storage.prototype, "getItem")
|
|
254
|
+
.mockImplementation(() => {
|
|
255
|
+
throw new Error("blocked");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const store = makeStore();
|
|
259
|
+
await expect(store.set("cvt_alpha")).resolves.toBeUndefined();
|
|
260
|
+
expect(await store.get()).toBe("cvt_alpha");
|
|
261
|
+
|
|
262
|
+
setItem.mockRestore();
|
|
263
|
+
getItem.mockRestore();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("detects same-document drift on get() and reports it as external", async () => {
|
|
267
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
268
|
+
const store = makeStore();
|
|
269
|
+
await store.set("cvt_alpha");
|
|
270
|
+
|
|
271
|
+
const changes: VisitorStoreChange[] = [];
|
|
272
|
+
store.subscribe((change) => changes.push(change));
|
|
273
|
+
|
|
274
|
+
// A sibling writer in this same document fires no storage event.
|
|
275
|
+
window.localStorage.setItem(storageKey, "cvt_gamma");
|
|
276
|
+
|
|
277
|
+
expect(await store.get()).toBe("cvt_gamma");
|
|
278
|
+
expect(store.revision()).toBe(2);
|
|
279
|
+
expect(changes).toEqual([
|
|
280
|
+
{
|
|
281
|
+
previousToken: "cvt_alpha",
|
|
282
|
+
token: "cvt_gamma",
|
|
283
|
+
source: "external",
|
|
284
|
+
revision: 2,
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("stops notifying after unsubscribe and after destroy", async () => {
|
|
290
|
+
const { storageKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
291
|
+
const store = makeStore();
|
|
292
|
+
const first = vi.fn();
|
|
293
|
+
const unsubscribe = store.subscribe(first);
|
|
294
|
+
|
|
295
|
+
await store.set("cvt_alpha");
|
|
296
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
297
|
+
|
|
298
|
+
unsubscribe();
|
|
299
|
+
await store.set("cvt_beta");
|
|
300
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
301
|
+
|
|
302
|
+
store.destroy();
|
|
303
|
+
const afterDestroy = vi.fn();
|
|
304
|
+
store.subscribe(afterDestroy);
|
|
305
|
+
const revisionAtDestroy = store.revision();
|
|
306
|
+
|
|
307
|
+
window.localStorage.setItem(storageKey, "cvt_delta");
|
|
308
|
+
window.dispatchEvent(
|
|
309
|
+
new StorageEvent("storage", {
|
|
310
|
+
key: storageKey,
|
|
311
|
+
oldValue: "cvt_beta",
|
|
312
|
+
newValue: "cvt_delta",
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(afterDestroy).not.toHaveBeenCalled();
|
|
317
|
+
expect(store.revision()).toBe(revisionAtDestroy);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("matches only on the full identity tuple", () => {
|
|
321
|
+
const store = makeStore();
|
|
322
|
+
|
|
323
|
+
expect(store.matches(CLIENT_TOKEN, PREFIX, false)).toBe(true);
|
|
324
|
+
expect(store.matches("ct_live_other", PREFIX, false)).toBe(false);
|
|
325
|
+
expect(store.matches(CLIENT_TOKEN, "myapp-", false)).toBe(false);
|
|
326
|
+
expect(store.matches(CLIENT_TOKEN, PREFIX, true)).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("serializes first init through Web Locks when available", async () => {
|
|
330
|
+
const { requests } = installFakeLocks();
|
|
331
|
+
const { lockName } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
332
|
+
const store = makeStore();
|
|
333
|
+
await store.ready;
|
|
334
|
+
|
|
335
|
+
const order: string[] = [];
|
|
336
|
+
let releaseFirst: () => void = () => {};
|
|
337
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
338
|
+
releaseFirst = resolve;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const first = store.withFirstInitLock(async () => {
|
|
342
|
+
order.push("first:start");
|
|
343
|
+
await firstGate;
|
|
344
|
+
order.push("first:end");
|
|
345
|
+
return "a";
|
|
346
|
+
});
|
|
347
|
+
const second = store.withFirstInitLock(async () => {
|
|
348
|
+
order.push("second:start");
|
|
349
|
+
return "b";
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await flush();
|
|
353
|
+
expect(order).toEqual(["first:start"]);
|
|
354
|
+
|
|
355
|
+
releaseFirst();
|
|
356
|
+
expect(await first).toBe("a");
|
|
357
|
+
expect(await second).toBe("b");
|
|
358
|
+
expect(order).toEqual(["first:start", "first:end", "second:start"]);
|
|
359
|
+
expect(requests).toEqual([lockName, lockName]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("falls back to a secret-free localStorage lease without Web Locks", async () => {
|
|
363
|
+
expect((globalThis.navigator as NavigatorWithLocks).locks).toBeUndefined();
|
|
364
|
+
|
|
365
|
+
const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
366
|
+
const store = makeStore();
|
|
367
|
+
await store.set("cvt_secret_value");
|
|
368
|
+
|
|
369
|
+
const seen: { lease: string | null } = { lease: null };
|
|
370
|
+
const result = await store.withFirstInitLock(async () => {
|
|
371
|
+
seen.lease = window.localStorage.getItem(leaseKey);
|
|
372
|
+
return "done";
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(result).toBe("done");
|
|
376
|
+
expect(typeof seen.lease).toBe("string");
|
|
377
|
+
|
|
378
|
+
const raw = String(seen.lease);
|
|
379
|
+
const parsed = JSON.parse(raw) as LeaseShape;
|
|
380
|
+
expect(typeof parsed.ownerNonce).toBe("string");
|
|
381
|
+
expect(typeof parsed.expiresAt).toBe("number");
|
|
382
|
+
expect(Number(parsed.expiresAt)).toBeGreaterThan(Date.now());
|
|
383
|
+
expect(raw).not.toContain(CLIENT_TOKEN);
|
|
384
|
+
expect(raw).not.toContain("cvt_");
|
|
385
|
+
|
|
386
|
+
expect(window.localStorage.getItem(leaseKey)).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("recovers an expired foreign lease", async () => {
|
|
390
|
+
const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
391
|
+
window.localStorage.setItem(
|
|
392
|
+
leaseKey,
|
|
393
|
+
JSON.stringify({ ownerNonce: "foreign", expiresAt: Date.now() - 60_000 })
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const store = makeStore();
|
|
397
|
+
const fn = vi.fn(async () => "minted");
|
|
398
|
+
|
|
399
|
+
expect(await store.withFirstInitLock(fn)).toBe("minted");
|
|
400
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
401
|
+
expect(window.localStorage.getItem(leaseKey)).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("lets two contending instances both complete and leaves no lease row", async () => {
|
|
405
|
+
const { leaseKey } = await visitorStoreKeys(CLIENT_TOKEN, PREFIX);
|
|
406
|
+
const storeA = makeStore();
|
|
407
|
+
const storeB = makeStore();
|
|
408
|
+
await Promise.all([storeA.ready, storeB.ready]);
|
|
409
|
+
|
|
410
|
+
const [a, b] = await Promise.all([
|
|
411
|
+
storeA.withFirstInitLock(async () => {
|
|
412
|
+
await sleep(5);
|
|
413
|
+
return "a";
|
|
414
|
+
}),
|
|
415
|
+
storeB.withFirstInitLock(async () => {
|
|
416
|
+
await sleep(5);
|
|
417
|
+
return "b";
|
|
418
|
+
}),
|
|
419
|
+
]);
|
|
420
|
+
|
|
421
|
+
expect(a).toBe("a");
|
|
422
|
+
expect(b).toBe("b");
|
|
423
|
+
expect(window.localStorage.getItem(leaseKey)).toBeNull();
|
|
424
|
+
}, 20_000);
|
|
425
|
+
});
|