@celestia-island/hikari 0.49.0 → 0.49.1
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/package.json +1 -1
- package/src/components/HkBlockingToast.scss +1 -1
- package/src/components/HkDatePicker.test.ts +55 -17
- package/src/components/HkDateTimePicker.test.ts +66 -27
- package/src/components/HkToast.gutter.contract.test.ts +25 -0
- package/src/components/HkToast.scss +1 -1
- package/src/runtime/historySafetyNet.test.ts +25 -0
- package/src/runtime/historySafetyNet.ts +14 -4
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
top: 4rem;
|
|
12
12
|
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
13
13
|
* 8px mobile) — the toast stack is a floating layer too. */
|
|
14
|
-
inset-inline-end: var(--viewport-gutter,
|
|
14
|
+
inset-inline-end: var(--viewport-gutter, 16px);
|
|
15
15
|
z-index: var(--hk-z-toast, 4000);
|
|
16
16
|
max-width: 24rem;
|
|
17
17
|
pointer-events: none;
|
|
@@ -48,6 +48,43 @@ async function settle() {
|
|
|
48
48
|
await nextTick();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
52
|
+
* transition is frame/timeout based and a fixed 20 ms settle raced it on
|
|
53
|
+
* the CI runner (the year-grid test once read the previous view's title),
|
|
54
|
+
* so the view STATE — not a sleep — is what this waits on. */
|
|
55
|
+
function titleText(): string {
|
|
56
|
+
return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn")?.textContent ?? "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForTitle(expected: string): Promise<void> {
|
|
60
|
+
const deadline = Date.now() + 2000;
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (titleText() === expected) return;
|
|
63
|
+
if (Date.now() > deadline) {
|
|
64
|
+
throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(titleText())})`);
|
|
65
|
+
}
|
|
66
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generic state poll over the same drill race: wait until `probe` holds. */
|
|
71
|
+
async function waitForView(desc: string, probe: () => boolean): Promise<void> {
|
|
72
|
+
const deadline = Date.now() + 2000;
|
|
73
|
+
while (!probe()) {
|
|
74
|
+
if (Date.now() > deadline) throw new Error(`view never reached: ${desc}`);
|
|
75
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function titleBtn(): HTMLButtonElement | null | undefined {
|
|
80
|
+
return panel()?.querySelector<HTMLButtonElement>(".hk-dp-title-btn");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The pick cells of the CURRENT view (months or years, both carry 12). */
|
|
84
|
+
function pickCells(): HTMLButtonElement[] {
|
|
85
|
+
return Array.from(panel()?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell[data-variant='pick']") ?? []);
|
|
86
|
+
}
|
|
87
|
+
|
|
51
88
|
function openViaEnter(harness: PickerHarness) {
|
|
52
89
|
const trigger = harness.container.querySelector<HTMLElement>(".hk-dp-trigger");
|
|
53
90
|
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
@@ -326,23 +363,24 @@ describe("HkDatePicker", () => {
|
|
|
326
363
|
const p = mountPicker({ modelValue: "2026-08-16" });
|
|
327
364
|
openViaEnter(p);
|
|
328
365
|
await nextTick();
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
366
|
+
// Every drill below waits for the DESTINATION view's state, not a
|
|
367
|
+
// fixed settle: the transition is frame/timeout based and the CI
|
|
368
|
+
// runner raced both the days→months click (the second click landed
|
|
369
|
+
// mid-transition and was swallowed) and the months→years read.
|
|
370
|
+
// days → months: the title button becomes the view year.
|
|
371
|
+
titleBtn()?.click();
|
|
372
|
+
await waitForView("the months grid of 2026", () => titleText() === "2026");
|
|
373
|
+
// months → years (the year button is the months-view title): the
|
|
374
|
+
// 2016–2027 block of the 2026 view year holds a 2027 cell.
|
|
375
|
+
titleBtn()?.click();
|
|
376
|
+
await waitForView("the year grid showing 2027", () =>
|
|
377
|
+
pickCells().some((c) => c.textContent === "2027"));
|
|
378
|
+
pickCells().find((c) => c.textContent === "2027")?.click();
|
|
379
|
+
// Picking a year lands on the months grid of that year.
|
|
380
|
+
await waitForTitle("2027");
|
|
381
|
+
pickCells()[6]?.click(); // July
|
|
382
|
+
// ...and picking a month lands back on the days grid.
|
|
383
|
+
await waitForView("the days grid", () => panel()?.querySelectorAll(".hk-dp-cell").length === 42);
|
|
346
384
|
const fmt = new Intl.DateTimeFormat("en", { year: "numeric", month: "long" });
|
|
347
385
|
expect(panel()?.querySelector<HTMLElement>(".hk-dp-title")?.textContent).toContain(
|
|
348
386
|
fmt.format(new Date(2027, 6, 1)),
|
|
@@ -8,6 +8,29 @@ const mounts: ReturnType<typeof createApp>[] = [];
|
|
|
8
8
|
const containers: HTMLElement[] = [];
|
|
9
9
|
const originalWidth = window.innerWidth;
|
|
10
10
|
|
|
11
|
+
/** The fixed model the suite mounts with. Every expected year/month/day
|
|
12
|
+
* in the assertions derives from THIS constant — never from a bare
|
|
13
|
+
* literal, and never from Date.now() — so the file cannot rot when a
|
|
14
|
+
* calendar year rolls over. */
|
|
15
|
+
const BASE = new Date(2026, 7, 16, 9, 30);
|
|
16
|
+
|
|
17
|
+
/** Local-ISO day (`YYYY-MM-DD`) — the native date input's wire format. */
|
|
18
|
+
function isoDay(d: Date): string {
|
|
19
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Local-ISO minute (`YYYY-MM-DDTHH:mm`) — datetime-local's wire format. */
|
|
23
|
+
function isoMinute(d: Date): string {
|
|
24
|
+
return `${isoDay(d)}T${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A day inside the mounted (BASE) month, optionally with a clock time. */
|
|
28
|
+
function dayOf(day: number, hours?: number, minutes?: number): Date {
|
|
29
|
+
return hours === undefined
|
|
30
|
+
? new Date(BASE.getFullYear(), BASE.getMonth(), day)
|
|
31
|
+
: new Date(BASE.getFullYear(), BASE.getMonth(), day, hours, minutes ?? 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
interface PickerHarness {
|
|
12
35
|
container: HTMLElement;
|
|
13
36
|
emitted: Date[];
|
|
@@ -18,7 +41,7 @@ function mountPicker(props: Record<string, unknown> = {}): PickerHarness {
|
|
|
18
41
|
document.body.appendChild(container);
|
|
19
42
|
containers.push(container);
|
|
20
43
|
|
|
21
|
-
const value = ref(props.modelValue as Date ??
|
|
44
|
+
const value = ref(props.modelValue as Date ?? BASE);
|
|
22
45
|
const emitted: Date[] = [];
|
|
23
46
|
const Wrapper = defineComponent({
|
|
24
47
|
setup() {
|
|
@@ -44,6 +67,22 @@ async function settle() {
|
|
|
44
67
|
await nextTick();
|
|
45
68
|
}
|
|
46
69
|
|
|
70
|
+
/** Poll until the drilled view's title button reads `expected`. The drill
|
|
71
|
+
* transition is frame/timeout based and a fixed 20 ms settle raced it
|
|
72
|
+
* under full-suite load (the assertion once read the days-view title),
|
|
73
|
+
* so the view STATE — not a sleep — is what this waits on. */
|
|
74
|
+
async function waitForTitle(expected: string): Promise<void> {
|
|
75
|
+
const deadline = Date.now() + 2000;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const title = picker()?.querySelector<HTMLButtonElement>(".hk-dtp-title-btn")?.textContent ?? "";
|
|
78
|
+
if (title === expected) return;
|
|
79
|
+
if (Date.now() > deadline) {
|
|
80
|
+
throw new Error(`title never became ${JSON.stringify(expected)} (last: ${JSON.stringify(title)})`);
|
|
81
|
+
}
|
|
82
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
function picker(): HTMLElement | null {
|
|
48
87
|
return document.querySelector<HTMLElement>(".hk-dtp");
|
|
49
88
|
}
|
|
@@ -124,7 +163,9 @@ describe("HkDateTimePicker", () => {
|
|
|
124
163
|
|
|
125
164
|
it("re-derives month and weekday labels after a locale switch", async () => {
|
|
126
165
|
mountPicker();
|
|
127
|
-
const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(
|
|
166
|
+
const zhMonth = new Intl.DateTimeFormat("zh-Hans", { month: "long" }).format(
|
|
167
|
+
new Date(BASE.getFullYear(), BASE.getMonth(), 1),
|
|
168
|
+
);
|
|
128
169
|
await setLocale("zh-Hans");
|
|
129
170
|
await nextTick();
|
|
130
171
|
const title = picker()?.querySelector<HTMLElement>(".hk-dtp-title-btn")?.textContent ?? "";
|
|
@@ -173,14 +214,14 @@ describe("HkDateTimePicker", () => {
|
|
|
173
214
|
const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
|
|
174
215
|
monthBtn?.click();
|
|
175
216
|
await settle();
|
|
176
|
-
|
|
217
|
+
await waitForTitle(String(BASE.getFullYear()));
|
|
177
218
|
const navs = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-nav");
|
|
178
219
|
navs?.[1].click();
|
|
179
220
|
await nextTick();
|
|
180
|
-
|
|
221
|
+
await waitForTitle(String(BASE.getFullYear() + 1));
|
|
181
222
|
navs?.[0].click();
|
|
182
223
|
await nextTick();
|
|
183
|
-
|
|
224
|
+
await waitForTitle(String(BASE.getFullYear()));
|
|
184
225
|
});
|
|
185
226
|
|
|
186
227
|
it("time stepper bumps keep the drilled month view instead of snapping back", async () => {
|
|
@@ -205,14 +246,14 @@ describe("HkDateTimePicker", () => {
|
|
|
205
246
|
expect(p.emitted.length).toBe(1);
|
|
206
247
|
const d = p.emitted[0];
|
|
207
248
|
expect(d instanceof Date).toBe(true);
|
|
208
|
-
expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([
|
|
249
|
+
expect([d.getFullYear(), d.getMonth(), d.getDate()]).toEqual([BASE.getFullYear(), BASE.getMonth(), 20]);
|
|
209
250
|
expect([d.getHours(), d.getMinutes()]).toEqual([9, 30]);
|
|
210
251
|
});
|
|
211
252
|
|
|
212
253
|
it("disables days outside the inclusive min/max bounds", () => {
|
|
213
254
|
mountPicker({
|
|
214
|
-
min: new Date(
|
|
215
|
-
max: new Date(
|
|
255
|
+
min: new Date(BASE.getFullYear(), BASE.getMonth(), 10, 0, 0),
|
|
256
|
+
max: new Date(BASE.getFullYear(), BASE.getMonth(), 20, 23, 59),
|
|
216
257
|
});
|
|
217
258
|
const byDay = new Map(
|
|
218
259
|
dayCells().filter((c) => !c.classList.contains("is-out")).map((c) => [c.textContent ?? "", c]),
|
|
@@ -224,14 +265,14 @@ describe("HkDateTimePicker", () => {
|
|
|
224
265
|
});
|
|
225
266
|
|
|
226
267
|
it("blocked days never emit an update when clicked", async () => {
|
|
227
|
-
const p = mountPicker({ max:
|
|
268
|
+
const p = mountPicker({ max: dayOf(20, 23, 59) });
|
|
228
269
|
clickDay(25);
|
|
229
270
|
await nextTick();
|
|
230
271
|
expect(p.emitted).toEqual([]);
|
|
231
272
|
});
|
|
232
273
|
|
|
233
274
|
it("marks days present in markedDays with a dot", () => {
|
|
234
|
-
mountPicker({ markedDays: new Set([
|
|
275
|
+
mountPicker({ markedDays: new Set([isoDay(dayOf(18))]) });
|
|
235
276
|
const marked = Array.from(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell") ?? [])
|
|
236
277
|
.filter((c) => c.querySelector(".hk-dtp-cell-dot"));
|
|
237
278
|
expect(marked.length).toBe(1);
|
|
@@ -257,7 +298,7 @@ describe("HkDateTimePicker", () => {
|
|
|
257
298
|
const input = nativeInput(p.container);
|
|
258
299
|
expect(input).not.toBeNull();
|
|
259
300
|
expect(input?.type).toBe("datetime-local");
|
|
260
|
-
expect(input?.value).toBe(
|
|
301
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
261
302
|
expect(p.container.querySelector(".hk-dtp")).toBeNull();
|
|
262
303
|
expect(p.container.querySelector(".hk-dtp-grid")).toBeNull();
|
|
263
304
|
});
|
|
@@ -267,18 +308,15 @@ describe("HkDateTimePicker", () => {
|
|
|
267
308
|
const p = mountPicker({ showTime: false });
|
|
268
309
|
const input = nativeInput(p.container);
|
|
269
310
|
expect(input?.type).toBe("date");
|
|
270
|
-
expect(input?.value).toBe(
|
|
311
|
+
expect(input?.value).toBe(isoDay(BASE));
|
|
271
312
|
});
|
|
272
313
|
|
|
273
314
|
it("passes min/max through to the native input in its wire format", () => {
|
|
274
315
|
useMobileViewport();
|
|
275
|
-
const p = mountPicker({
|
|
276
|
-
min: new Date(2026, 7, 10, 8, 0),
|
|
277
|
-
max: new Date(2026, 7, 20, 18, 30),
|
|
278
|
-
});
|
|
316
|
+
const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30) });
|
|
279
317
|
const input = nativeInput(p.container);
|
|
280
|
-
expect(input?.min).toBe(
|
|
281
|
-
expect(input?.max).toBe(
|
|
318
|
+
expect(input?.min).toBe(isoMinute(dayOf(10, 8, 0)));
|
|
319
|
+
expect(input?.max).toBe(isoMinute(dayOf(20, 18, 30)));
|
|
282
320
|
});
|
|
283
321
|
|
|
284
322
|
it("keeps the custom calendar on mobile when nativeOnMobile is false", () => {
|
|
@@ -295,33 +333,34 @@ describe("HkDateTimePicker", () => {
|
|
|
295
333
|
useMobileViewport();
|
|
296
334
|
const p = mountPicker();
|
|
297
335
|
const input = nativeInput(p.container);
|
|
298
|
-
input!.value =
|
|
336
|
+
input!.value = isoMinute(dayOf(20, 14, 5));
|
|
299
337
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
300
338
|
await nextTick();
|
|
301
339
|
expect(p.emitted.length).toBe(1);
|
|
302
|
-
expect(p.emitted[0].getTime()).toBe(
|
|
303
|
-
expect(input?.value).toBe(
|
|
340
|
+
expect(p.emitted[0].getTime()).toBe(dayOf(20, 14, 5).getTime());
|
|
341
|
+
expect(input?.value).toBe(isoMinute(dayOf(20, 14, 5)));
|
|
304
342
|
});
|
|
305
343
|
|
|
306
344
|
it("native date edits preserve the clock time when showTime is false", async () => {
|
|
307
345
|
useMobileViewport();
|
|
308
346
|
const p = mountPicker({ showTime: false });
|
|
309
347
|
const input = nativeInput(p.container);
|
|
310
|
-
input!.value =
|
|
348
|
+
input!.value = isoDay(dayOf(20));
|
|
311
349
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
312
350
|
await nextTick();
|
|
313
|
-
expect(p.emitted[0].getTime()).toBe(
|
|
351
|
+
expect(p.emitted[0].getTime()).toBe(dayOf(20, 9, 30).getTime());
|
|
314
352
|
});
|
|
315
353
|
|
|
316
354
|
it("native input edits outside the bounds are rejected and re-synced", async () => {
|
|
317
355
|
useMobileViewport();
|
|
318
|
-
const p = mountPicker({ max:
|
|
356
|
+
const p = mountPicker({ max: dayOf(20, 23, 59) });
|
|
319
357
|
const input = nativeInput(p.container);
|
|
320
|
-
|
|
358
|
+
// Next month, past the max bound.
|
|
359
|
+
input!.value = isoMinute(new Date(BASE.getFullYear(), BASE.getMonth() + 1, 1, 10, 0));
|
|
321
360
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
322
361
|
await nextTick();
|
|
323
362
|
expect(p.emitted).toEqual([]);
|
|
324
|
-
expect(input?.value).toBe(
|
|
363
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
325
364
|
});
|
|
326
365
|
|
|
327
366
|
it("clearing the native input falls back to the model value", async () => {
|
|
@@ -332,7 +371,7 @@ describe("HkDateTimePicker", () => {
|
|
|
332
371
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
333
372
|
await nextTick();
|
|
334
373
|
expect(p.emitted).toEqual([]);
|
|
335
|
-
expect(input?.value).toBe(
|
|
374
|
+
expect(input?.value).toBe(isoMinute(BASE));
|
|
336
375
|
});
|
|
337
376
|
|
|
338
377
|
it("native input replaces even the popup chrome on mobile", () => {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract: the toast stacks are floating layers too, so their
|
|
3
|
+
* screen-edge inset rides the same shared --viewport-gutter token as the
|
|
4
|
+
* anchored popups (16px desktop / 8px mobile, hikari #501). Pinned here
|
|
5
|
+
* so a refactor cannot quietly re-hardcode a pixel value or drift the
|
|
6
|
+
* fallback away from the token's declared desktop magnitude.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
describe("toast container viewport gutter contract", () => {
|
|
16
|
+
for (const sheet of ["HkToast.scss", "HkBlockingToast.scss"]) {
|
|
17
|
+
it(`${sheet} pins the container inset to the shared gutter token`, () => {
|
|
18
|
+
const css = readFileSync(join(here, sheet), "utf-8");
|
|
19
|
+
// EVERY inset-inline-end declaration in the sheet must be the
|
|
20
|
+
// gutter var — no re-hardcoded pixel sibling may creep in.
|
|
21
|
+
const decls = css.match(/inset-inline-end:[^;]+;/g) ?? [];
|
|
22
|
+
expect(decls).toEqual(["inset-inline-end: var(--viewport-gutter, 16px);"]);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
top: 4rem;
|
|
4
4
|
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
5
5
|
* 8px mobile) — the toast stack is a floating layer too. */
|
|
6
|
-
inset-inline-end: var(--viewport-gutter,
|
|
6
|
+
inset-inline-end: var(--viewport-gutter, 16px);
|
|
7
7
|
/* Toast band — the topmost popup z band (POPUP_Z_BANDS.toast; mirror in
|
|
8
8
|
theme.scss). The live value arrives inline from the popup manager. */
|
|
9
9
|
z-index: var(--hk-z-toast, 4000);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { installHistorySafetyNet, sanitizeHistoryUrl } from "./historySafetyNet";
|
|
4
|
+
import { getHkRuntimeEntry } from "./registry";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The field bug this net closes (chest #754 lineage): some producer
|
|
@@ -136,4 +137,28 @@ describe("installHistorySafetyNet", () => {
|
|
|
136
137
|
expect(sessionStorage.getItem("hikari:historyNet")).toBeNull();
|
|
137
138
|
vi.restoreAllMocks();
|
|
138
139
|
});
|
|
140
|
+
|
|
141
|
+
it("keeps the first install's fold target when a HALF-overwritten net re-patches", () => {
|
|
142
|
+
// The recorded half-self-heal edge: pushState keeps our patch while an
|
|
143
|
+
// external lib restores replaceState to native. A re-install with new
|
|
144
|
+
// options must NOT silently adopt them for the re-patched method —
|
|
145
|
+
// the still-installed first capture wins for behavior AND meta, or a
|
|
146
|
+
// per-method option split ships to production unnoticed.
|
|
147
|
+
installHistorySafetyNet({ fallback: "/" });
|
|
148
|
+
// Another library restores replaceState (pushState stays ours).
|
|
149
|
+
History.prototype.replaceState = nativeReplace;
|
|
150
|
+
// Re-install with a different fallback: the pushState marker makes
|
|
151
|
+
// this a no-op claim, so "/" must stay the active fold target.
|
|
152
|
+
installHistorySafetyNet({ fallback: "/landing" });
|
|
153
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
154
|
+
history.replaceState(null, "", "https://evil.example/x");
|
|
155
|
+
// Folded to the FIRST install's "/" — never the re-install's
|
|
156
|
+
// "/landing" (which, being same-origin, would have navigated).
|
|
157
|
+
expect(window.location.pathname).toBe("/");
|
|
158
|
+
expect(sessionStorage.getItem("hikari:historyNet")).toContain("evil.example/x");
|
|
159
|
+
// ...and the registry meta reports the option set that is actually
|
|
160
|
+
// wired into the prototype, not the newest call's.
|
|
161
|
+
expect(getHkRuntimeEntry("historySafetyNet")?.meta?.fallback).toBe("/");
|
|
162
|
+
warn.mockRestore();
|
|
163
|
+
});
|
|
139
164
|
});
|
|
@@ -95,9 +95,15 @@ let historyNetRuntime: ReturnType<typeof reportHkRuntime> | null = null;
|
|
|
95
95
|
* our patch is still on the prototype skips re-patching (the `continue`
|
|
96
96
|
* below), so the first install's fallback/evidence stay in effect — the
|
|
97
97
|
* registry meta must report THOSE, not the newest call's options, or it
|
|
98
|
-
* would misreport the active fold target.
|
|
98
|
+
* would misreport the active fold target. The re-patch closure reads
|
|
99
|
+
* THESE module captures, never the invocation locals: when a net is
|
|
100
|
+
* HALF overwritten (say replaceState restored to native while pushState
|
|
101
|
+
* is still ours) a re-install patches the foreign method with the
|
|
102
|
+
* still-active capture, so behavior and meta can never diverge into
|
|
103
|
+
* per-method options. */
|
|
99
104
|
let activeFallback = "/";
|
|
100
105
|
let activeEvidenceKey: string | false = DEFAULT_EVIDENCE_KEY;
|
|
106
|
+
let activeOptions: HistorySafetyNetOptions = {};
|
|
101
107
|
function ensureHistoryNetRuntime() {
|
|
102
108
|
return (historyNetRuntime ??= reportHkRuntime("historySafetyNet", {
|
|
103
109
|
kind: "hook",
|
|
@@ -120,6 +126,7 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
|
|
|
120
126
|
// active; a no-op re-install keeps the first install's capture.
|
|
121
127
|
activeFallback = fallback;
|
|
122
128
|
activeEvidenceKey = evidenceKey;
|
|
129
|
+
activeOptions = options;
|
|
123
130
|
}
|
|
124
131
|
ensureHistoryNetRuntime().setMeta({ fallback: activeFallback, evidenceKey: activeEvidenceKey === false ? "off" : activeEvidenceKey });
|
|
125
132
|
ensureHistoryNetRuntime().pulse();
|
|
@@ -133,13 +140,16 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
|
|
|
133
140
|
}
|
|
134
141
|
const native = current as (this: History, state: unknown, title: string, url?: string | URL | null) => unknown;
|
|
135
142
|
if (typeof native !== "function") continue;
|
|
143
|
+
// The closure reads the ACTIVE capture, never this call's locals —
|
|
144
|
+
// the half-overwrite path above re-patches a foreign method while
|
|
145
|
+
// the first install's options stay in effect.
|
|
136
146
|
const patched = function (this: History, state: unknown, title: string, url?: string | URL | null) {
|
|
137
|
-
const safe = sanitizeHistoryUrl(url,
|
|
147
|
+
const safe = sanitizeHistoryUrl(url, activeFallback);
|
|
138
148
|
if (safe !== url) {
|
|
139
|
-
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo:
|
|
149
|
+
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: activeFallback };
|
|
140
150
|
coercions += 1;
|
|
141
151
|
historyNetRuntime?.pulse({ coercions });
|
|
142
|
-
reportCoercion(info,
|
|
152
|
+
reportCoercion(info, activeEvidenceKey, activeOptions);
|
|
143
153
|
}
|
|
144
154
|
return native.call(this, state as never, title, safe as never);
|
|
145
155
|
};
|