@celestia-island/hikari 0.55.144 → 0.55.146
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/HkDatePicker.test.ts +21 -20
- package/src/components/HkDatePicker.tsx +6 -2
- package/src/components/HkDateTimePicker.test.ts +15 -14
- package/src/components/HkDateTimePicker.tsx +1 -1
- package/src/components/HkFilterBar.tsx +1 -0
- package/src/components/HkFilterPanel.tsx +13 -1
- package/src/composables/useConnectionProbe.test.ts +179 -0
- package/src/composables/useConnectionProbe.ts +54 -14
- package/src/composables/useEngineHealth.test.ts +128 -0
- package/src/composables/useEngineHealth.ts +28 -4
package/package.json
CHANGED
|
@@ -194,7 +194,7 @@ describe("HkDatePicker", () => {
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
it("disables days outside the inclusive min/max bounds", async () => {
|
|
197
|
-
const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20" });
|
|
197
|
+
const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20", nativeOnMobile: true });
|
|
198
198
|
openViaEnter(p);
|
|
199
199
|
await nextTick();
|
|
200
200
|
const byDay = new Map(
|
|
@@ -453,22 +453,12 @@ describe("HkDatePicker", () => {
|
|
|
453
453
|
expect(stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length).toBe(42);
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
// ── Mobile
|
|
456
|
+
// ── Mobile: the CALENDAR is the default; the native input is the
|
|
457
|
+
// ── explicit opt-out (2026-10-11 direction: no native controls) ──
|
|
457
458
|
|
|
458
|
-
it("
|
|
459
|
+
it("keeps the calendar on mobile by default (no native input)", async () => {
|
|
459
460
|
useMobileViewport();
|
|
460
461
|
const p = mountPicker({ modelValue: "2026-08-16" });
|
|
461
|
-
const input = nativeInput(p.container);
|
|
462
|
-
expect(input).not.toBeNull();
|
|
463
|
-
expect(input?.type).toBe("date");
|
|
464
|
-
expect(input?.value).toBe("2026-08-16");
|
|
465
|
-
expect(p.container.querySelector(".hk-dp-trigger")).toBeNull();
|
|
466
|
-
expect(p.container.querySelector(".hk-dp-panel")).toBeNull();
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it("keeps the custom calendar on mobile when nativeOnMobile is false", async () => {
|
|
470
|
-
useMobileViewport();
|
|
471
|
-
const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: false });
|
|
472
462
|
expect(p.container.querySelector(".hk-dp-trigger")).not.toBeNull();
|
|
473
463
|
expect(nativeInput(p.container)).toBeNull();
|
|
474
464
|
openViaEnter(p);
|
|
@@ -479,17 +469,28 @@ describe("HkDatePicker", () => {
|
|
|
479
469
|
expect(panel()?.classList.contains("is-touch")).toBe(true);
|
|
480
470
|
});
|
|
481
471
|
|
|
482
|
-
it("
|
|
472
|
+
it("renders a native date input on mobile only when nativeOnMobile opts in", () => {
|
|
473
|
+
useMobileViewport();
|
|
474
|
+
const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: true });
|
|
475
|
+
const input = nativeInput(p.container);
|
|
476
|
+
expect(input).not.toBeNull();
|
|
477
|
+
expect(input?.type).toBe("date");
|
|
478
|
+
expect(input?.value).toBe("2026-08-16");
|
|
479
|
+
expect(p.container.querySelector(".hk-dp-trigger")).toBeNull();
|
|
480
|
+
expect(p.container.querySelector(".hk-dp-panel")).toBeNull();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("passes min/max through to the native input (explicit opt-in)", () => {
|
|
483
484
|
useMobileViewport();
|
|
484
|
-
const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20" });
|
|
485
|
+
const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20", nativeOnMobile: true });
|
|
485
486
|
const input = nativeInput(p.container);
|
|
486
487
|
expect(input?.min).toBe("2026-08-10");
|
|
487
488
|
expect(input?.max).toBe("2026-08-20");
|
|
488
489
|
});
|
|
489
490
|
|
|
490
|
-
it("native input edits emit the ISO date and clearing emits null", async () => {
|
|
491
|
+
it("native input edits emit the ISO date and clearing emits null (explicit opt-in)", async () => {
|
|
491
492
|
useMobileViewport();
|
|
492
|
-
const p = mountPicker({ modelValue: "2026-08-16" });
|
|
493
|
+
const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: true });
|
|
493
494
|
const input = nativeInput(p.container);
|
|
494
495
|
input!.value = "2026-08-20";
|
|
495
496
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -503,9 +504,9 @@ describe("HkDatePicker", () => {
|
|
|
503
504
|
expect(p.emitted).toEqual(["2026-08-20", null]);
|
|
504
505
|
});
|
|
505
506
|
|
|
506
|
-
it("native input edits outside the bounds are rejected and re-synced", async () => {
|
|
507
|
+
it("native input edits outside the bounds are rejected and re-synced (explicit opt-in)", async () => {
|
|
507
508
|
useMobileViewport();
|
|
508
|
-
const p = mountPicker({ modelValue: "2026-08-16", max: "2026-08-20" });
|
|
509
|
+
const p = mountPicker({ modelValue: "2026-08-16", max: "2026-08-20", nativeOnMobile: true });
|
|
509
510
|
const input = nativeInput(p.container);
|
|
510
511
|
input!.value = "2026-09-01";
|
|
511
512
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -73,8 +73,12 @@ export default defineComponent({
|
|
|
73
73
|
min: { type: String as PropType<string | undefined>, default: undefined },
|
|
74
74
|
/** Inclusive upper bound as an ISO date; later days render disabled. */
|
|
75
75
|
max: { type: String as PropType<string | undefined>, default: undefined },
|
|
76
|
-
/** Render the OS native `<input type="date">` on touch-sized viewports.
|
|
77
|
-
|
|
76
|
+
/** Render the OS native `<input type="date">` on touch-sized viewports.
|
|
77
|
+
* DEFAULTS FALSE since 2026-10-11 (user direction: no native controls
|
|
78
|
+
* anywhere) — the calendar serves phones as a bottom sheet with the
|
|
79
|
+
* enlarged touch chrome. The prop stays for one-off hosts that truly
|
|
80
|
+
* want the OS picker; new callers should not set it. */
|
|
81
|
+
nativeOnMobile: { type: Boolean, default: false },
|
|
78
82
|
},
|
|
79
83
|
emits: {
|
|
80
84
|
"update:modelValue": (_value: string | null) => true,
|
|
@@ -324,11 +324,12 @@ describe("HkDateTimePicker", () => {
|
|
|
324
324
|
expect(document.querySelectorAll(".hk-dtp-popup .hk-dtp-cell").length).toBe(42);
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
// ── Mobile
|
|
327
|
+
// ── Mobile: the calendar is the default; native is the explicit ──
|
|
328
|
+
// ── opt-out (2026-10-11 direction: no native controls) ───────────
|
|
328
329
|
|
|
329
|
-
it("renders a native datetime-local input
|
|
330
|
+
it("renders a native datetime-local input on mobile only when nativeOnMobile opts in", () => {
|
|
330
331
|
useMobileViewport();
|
|
331
|
-
const p = mountPicker();
|
|
332
|
+
const p = mountPicker({ nativeOnMobile: true });
|
|
332
333
|
const input = nativeInput(p.container);
|
|
333
334
|
expect(input).not.toBeNull();
|
|
334
335
|
expect(input?.type).toBe("datetime-local");
|
|
@@ -337,9 +338,9 @@ describe("HkDateTimePicker", () => {
|
|
|
337
338
|
expect(p.container.querySelector(".hk-dtp-grid")).toBeNull();
|
|
338
339
|
});
|
|
339
340
|
|
|
340
|
-
it("uses a native date input when showTime is false", () => {
|
|
341
|
+
it("uses a native date input when showTime is false (explicit opt-in)", () => {
|
|
341
342
|
useMobileViewport();
|
|
342
|
-
const p = mountPicker({ showTime: false });
|
|
343
|
+
const p = mountPicker({ showTime: false, nativeOnMobile: true });
|
|
343
344
|
const input = nativeInput(p.container);
|
|
344
345
|
expect(input?.type).toBe("date");
|
|
345
346
|
expect(input?.value).toBe(isoDay(BASE));
|
|
@@ -347,15 +348,15 @@ describe("HkDateTimePicker", () => {
|
|
|
347
348
|
|
|
348
349
|
it("passes min/max through to the native input in its wire format", () => {
|
|
349
350
|
useMobileViewport();
|
|
350
|
-
const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30) });
|
|
351
|
+
const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30), nativeOnMobile: true });
|
|
351
352
|
const input = nativeInput(p.container);
|
|
352
353
|
expect(input?.min).toBe(isoMinute(dayOf(10, 8, 0)));
|
|
353
354
|
expect(input?.max).toBe(isoMinute(dayOf(20, 18, 30)));
|
|
354
355
|
});
|
|
355
356
|
|
|
356
|
-
it("keeps the custom calendar on mobile
|
|
357
|
+
it("keeps the custom calendar on mobile by default", () => {
|
|
357
358
|
useMobileViewport();
|
|
358
|
-
const p = mountPicker(
|
|
359
|
+
const p = mountPicker();
|
|
359
360
|
expect(p.container.querySelector(".hk-dtp")).not.toBeNull();
|
|
360
361
|
expect(dayCells().length).toBe(42);
|
|
361
362
|
expect(nativeInput(p.container)).toBeNull();
|
|
@@ -365,7 +366,7 @@ describe("HkDateTimePicker", () => {
|
|
|
365
366
|
|
|
366
367
|
it("native input edits update the model as a local-time Date", async () => {
|
|
367
368
|
useMobileViewport();
|
|
368
|
-
const p = mountPicker();
|
|
369
|
+
const p = mountPicker({ nativeOnMobile: true });
|
|
369
370
|
const input = nativeInput(p.container);
|
|
370
371
|
input!.value = isoMinute(dayOf(20, 14, 5));
|
|
371
372
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -377,7 +378,7 @@ describe("HkDateTimePicker", () => {
|
|
|
377
378
|
|
|
378
379
|
it("native date edits preserve the clock time when showTime is false", async () => {
|
|
379
380
|
useMobileViewport();
|
|
380
|
-
const p = mountPicker({ showTime: false });
|
|
381
|
+
const p = mountPicker({ showTime: false, nativeOnMobile: true });
|
|
381
382
|
const input = nativeInput(p.container);
|
|
382
383
|
input!.value = isoDay(dayOf(20));
|
|
383
384
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -387,7 +388,7 @@ describe("HkDateTimePicker", () => {
|
|
|
387
388
|
|
|
388
389
|
it("native input edits outside the bounds are rejected and re-synced", async () => {
|
|
389
390
|
useMobileViewport();
|
|
390
|
-
const p = mountPicker({ max: dayOf(20, 23, 59) });
|
|
391
|
+
const p = mountPicker({ max: dayOf(20, 23, 59), nativeOnMobile: true });
|
|
391
392
|
const input = nativeInput(p.container);
|
|
392
393
|
// Next month, past the max bound.
|
|
393
394
|
input!.value = isoMinute(new Date(BASE.getFullYear(), BASE.getMonth() + 1, 1, 10, 0));
|
|
@@ -399,7 +400,7 @@ describe("HkDateTimePicker", () => {
|
|
|
399
400
|
|
|
400
401
|
it("clearing the native input falls back to the model value", async () => {
|
|
401
402
|
useMobileViewport();
|
|
402
|
-
const p = mountPicker();
|
|
403
|
+
const p = mountPicker({ nativeOnMobile: true });
|
|
403
404
|
const input = nativeInput(p.container);
|
|
404
405
|
input!.value = "";
|
|
405
406
|
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
@@ -408,9 +409,9 @@ describe("HkDateTimePicker", () => {
|
|
|
408
409
|
expect(input?.value).toBe(isoMinute(BASE));
|
|
409
410
|
});
|
|
410
411
|
|
|
411
|
-
it("native input replaces even the popup chrome on mobile", () => {
|
|
412
|
+
it("native input replaces even the popup chrome on mobile (explicit opt-in)", () => {
|
|
412
413
|
useMobileViewport();
|
|
413
|
-
const p = mountPicker({ mode: "popup", confirmLabel: "Confirm" });
|
|
414
|
+
const p = mountPicker({ mode: "popup", confirmLabel: "Confirm", nativeOnMobile: true });
|
|
414
415
|
expect(nativeInput(p.container)).not.toBeNull();
|
|
415
416
|
expect(p.container.querySelector(".hk-dtp-trigger")).toBeNull();
|
|
416
417
|
});
|
|
@@ -95,7 +95,7 @@ export default defineComponent({
|
|
|
95
95
|
confirmLabel: { type: String, default: undefined },
|
|
96
96
|
showTime: { type: Boolean, default: true },
|
|
97
97
|
/** Render the OS native `<input type="datetime-local">` on touch-sized viewports. */
|
|
98
|
-
nativeOnMobile: { type: Boolean, default:
|
|
98
|
+
nativeOnMobile: { type: Boolean, default: false }, // 2026-10-11: no native controls — the picker sheet serves phones
|
|
99
99
|
},
|
|
100
100
|
emits: {
|
|
101
101
|
"update:modelValue": (_d: Date) => true,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import HkDatePicker from "./HkDatePicker";
|
|
3
4
|
import HkInput from "./HkInput";
|
|
4
5
|
import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
|
|
5
6
|
import { useI18n } from "../i18n/context";
|
|
@@ -168,9 +169,20 @@ export default defineComponent({
|
|
|
168
169
|
disabled={props.disabled}
|
|
169
170
|
options={selectOptions(field)}
|
|
170
171
|
/>
|
|
172
|
+
) : field.kind === "date" ? (
|
|
173
|
+
// The calendar picker — never the OS native input
|
|
174
|
+
// (2026-10-11 direction); the wire format is the same
|
|
175
|
+
// ISO YYYY-MM-DD the native input spoke.
|
|
176
|
+
<HkDatePicker
|
|
177
|
+
modelValue={condition.value || null}
|
|
178
|
+
onUpdate:modelValue={(v: string | null) =>
|
|
179
|
+
emitCondition(field, { value: v ?? "" })
|
|
180
|
+
}
|
|
181
|
+
disabled={props.disabled}
|
|
182
|
+
/>
|
|
171
183
|
) : (
|
|
172
184
|
<HkInput
|
|
173
|
-
type=
|
|
185
|
+
type="text"
|
|
174
186
|
variant={field.kind === "number" ? "number" : "text"}
|
|
175
187
|
modelValue={condition.value}
|
|
176
188
|
onUpdate:modelValue={(v: string) =>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, type Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import { useConnectionProbe, type ProbeResult } from "./useConnectionProbe";
|
|
5
|
+
|
|
6
|
+
/** The probe is a mount-scoped composable (onMounted drives the first
|
|
7
|
+
* fetch); each test rides a minimal component harness. Fake timers own
|
|
8
|
+
* both the clock (latency, countdown ladder) and the interval bus. */
|
|
9
|
+
|
|
10
|
+
interface Deferred {
|
|
11
|
+
promise: Promise<Response>;
|
|
12
|
+
resolve: (r: Response) => void;
|
|
13
|
+
reject: (e: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function deferredResponse(): Deferred {
|
|
17
|
+
let resolve!: (r: Response) => void;
|
|
18
|
+
let reject!: (e: unknown) => void;
|
|
19
|
+
const promise = new Promise<Response>((res, rej) => {
|
|
20
|
+
resolve = res;
|
|
21
|
+
reject = rej;
|
|
22
|
+
});
|
|
23
|
+
return { promise, resolve, reject };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function okResponse(): Response {
|
|
27
|
+
return { ok: true } as Response;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mountProbe() {
|
|
31
|
+
let out!: { result: Ref<ProbeResult>; retryNow: () => void };
|
|
32
|
+
const app = createApp(
|
|
33
|
+
defineComponent({
|
|
34
|
+
setup() {
|
|
35
|
+
out = useConnectionProbe();
|
|
36
|
+
return () => h("div");
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const container = document.createElement("div");
|
|
41
|
+
document.body.appendChild(container);
|
|
42
|
+
app.mount(container);
|
|
43
|
+
return {
|
|
44
|
+
probe: () => out,
|
|
45
|
+
unmount: () => {
|
|
46
|
+
app.unmount();
|
|
47
|
+
container.remove();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
vi.unstubAllGlobals();
|
|
55
|
+
document.body.innerHTML = "";
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("useConnectionProbe continuous observation", () => {
|
|
59
|
+
it("keeps polling after a successful probe — parked-forever was the bug", async () => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
const fetchMock = vi.fn(() => Promise.resolve(okResponse()));
|
|
62
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
63
|
+
const { probe, unmount } = mountProbe();
|
|
64
|
+
try {
|
|
65
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
66
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(probe().result.value.state).toBe("connected");
|
|
68
|
+
|
|
69
|
+
// The scheduled re-check fires 15s later — the light KEEPS
|
|
70
|
+
// observing instead of recording the initial verdict forever.
|
|
71
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
72
|
+
expect(fetchMock, "healthy backend keeps being polled").toHaveBeenCalledTimes(2);
|
|
73
|
+
expect(probe().result.value.state).toBe("connected");
|
|
74
|
+
expect(probe().result.value.connected).toBe(true);
|
|
75
|
+
} finally {
|
|
76
|
+
unmount();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a healthy re-check stays GREEN in flight — no reconnecting blink, no flash", async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
const first = deferredResponse();
|
|
83
|
+
const second = deferredResponse();
|
|
84
|
+
// Route by call order: first mount fetch, then the scheduled re-check.
|
|
85
|
+
let calls = 0;
|
|
86
|
+
const fetchMock = vi.fn(() => {
|
|
87
|
+
calls += 1;
|
|
88
|
+
return calls === 1 ? first.promise : second.promise;
|
|
89
|
+
});
|
|
90
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
91
|
+
const { probe, unmount } = mountProbe();
|
|
92
|
+
try {
|
|
93
|
+
first.resolve(okResponse());
|
|
94
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
95
|
+
expect(probe().result.value.state).toBe("connected");
|
|
96
|
+
|
|
97
|
+
// Fire the scheduled re-check and let it hang mid-fetch: the OLD
|
|
98
|
+
// behavior flipped the state to "reconnecting" here (yellow blink
|
|
99
|
+
// + recovery flash every interval). Quiet re-checks must not.
|
|
100
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
101
|
+
await Promise.resolve(); // enter the awaiting fetch
|
|
102
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(probe().result.value.state, "in-flight re-check is quiet").toBe("connected");
|
|
104
|
+
expect(probe().result.value.connected).toBe(true);
|
|
105
|
+
|
|
106
|
+
second.resolve(okResponse());
|
|
107
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
108
|
+
expect(probe().result.value.state).toBe("connected");
|
|
109
|
+
} finally {
|
|
110
|
+
unmount();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("a re-check that fails drops into the retry ladder and recovers", async () => {
|
|
115
|
+
vi.useFakeTimers();
|
|
116
|
+
const fetchMock = vi.fn();
|
|
117
|
+
fetchMock.mockResolvedValueOnce(okResponse()); // mount: healthy
|
|
118
|
+
fetchMock.mockRejectedValueOnce(new Error("backend died")); // re-check: dead
|
|
119
|
+
fetchMock.mockResolvedValueOnce(okResponse()); // ladder retry: back
|
|
120
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
121
|
+
const { probe, unmount } = mountProbe();
|
|
122
|
+
try {
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
124
|
+
expect(probe().result.value.state).toBe("connected");
|
|
125
|
+
|
|
126
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
128
|
+
// The failure is REAL and must show: red, disconnected, ladder
|
|
129
|
+
// armed (attempt 1 of the retryTotal, countdown to the next try).
|
|
130
|
+
expect(probe().result.value.connected).toBe(false);
|
|
131
|
+
expect(probe().result.value.state).toBe("disconnected");
|
|
132
|
+
expect(probe().result.value.attemptNumber).toBe(1);
|
|
133
|
+
expect(probe().result.value.countdown).toBeGreaterThan(0);
|
|
134
|
+
|
|
135
|
+
// Ladder retry succeeds: green again, and the attempt counter
|
|
136
|
+
// RESET — a lifetime odometer would read "Retrying N/3" with N
|
|
137
|
+
// grown by every healthy poll.
|
|
138
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
139
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
140
|
+
expect(probe().result.value.connected).toBe(true);
|
|
141
|
+
expect(probe().result.value.state).toBe("connected");
|
|
142
|
+
expect(probe().result.value.attemptNumber).toBe(0);
|
|
143
|
+
} finally {
|
|
144
|
+
unmount();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("stops observing once unmounted", async () => {
|
|
149
|
+
vi.useFakeTimers();
|
|
150
|
+
const fetchMock = vi.fn(() => Promise.resolve(okResponse()));
|
|
151
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
152
|
+
const { probe, unmount } = mountProbe();
|
|
153
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
154
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
155
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
156
|
+
unmount();
|
|
157
|
+
await vi.advanceTimersByTimeAsync(45_000);
|
|
158
|
+
expect(fetchMock, "no polls after unmount").toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(probe().result.value.state).toBe("connected");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("an in-flight probe settling AFTER unmount arms no zombie loop", async () => {
|
|
163
|
+
vi.useFakeTimers();
|
|
164
|
+
const hang = deferredResponse();
|
|
165
|
+
const fetchMock = vi.fn(() => hang.promise);
|
|
166
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
167
|
+
const { probe, unmount } = mountProbe();
|
|
168
|
+
// The race (R2 catch, 2026-10-11): the mount fetch is still in
|
|
169
|
+
// flight when the component unmounts; its late SUCCESS used to arm
|
|
170
|
+
// a fresh 15s slot on a dead component — a poll loop that never
|
|
171
|
+
// stopped (4+ fetches per 45s, forever). The disposed guard must
|
|
172
|
+
// swallow the arming; the late state write itself is harmless.
|
|
173
|
+
unmount();
|
|
174
|
+
hang.resolve(okResponse());
|
|
175
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
176
|
+
expect(fetchMock, "no post-unmount polls, ever").toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(probe().result.value.connected, "late write is harmless").toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -78,23 +78,38 @@ export function useConnectionProbe(): {
|
|
|
78
78
|
let tickHandle: IntervalHandle | null = null;
|
|
79
79
|
let nextAttemptAt = 0;
|
|
80
80
|
let consecutiveFailures = 0;
|
|
81
|
+
// Set at unmount, read by schedulePoll: an in-flight probe resolving
|
|
82
|
+
// AFTER unmount must not arm a fresh slot — that race resurrected a
|
|
83
|
+
// 15s zombie loop polling forever on a dead component (R2 catch,
|
|
84
|
+
// 2026-10-11). Late state writes into the dead ref are harmless;
|
|
85
|
+
// late ARMING is not.
|
|
86
|
+
let disposed = false;
|
|
81
87
|
|
|
82
88
|
function applyCountdown(): void {
|
|
83
89
|
const seconds = Math.max(0, Math.ceil((nextAttemptAt - Date.now()) / 1000));
|
|
84
90
|
result.value = { ...result.value, countdown: seconds };
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
async function probeOnce(): Promise<void> {
|
|
93
|
+
async function probeOnce(opts?: { quiet?: boolean }): Promise<void> {
|
|
88
94
|
pollHandle?.disconnect();
|
|
89
95
|
pollHandle = null;
|
|
96
|
+
const wasConnected = result.value.connected;
|
|
97
|
+
// Quiet re-checks (the scheduled poll while healthy) keep the light
|
|
98
|
+
// GREEN through the fetch: flipping every healthy re-check through
|
|
99
|
+
// "reconnecting" would blink the pill and re-fire the recovery flash
|
|
100
|
+
// once per interval. A quiet re-check that FAILS falls through to
|
|
101
|
+
// the ladder below — that transition is real and must show.
|
|
102
|
+
const quiet = opts?.quiet === true && wasConnected;
|
|
90
103
|
const attempt = result.value.attemptNumber + 1;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
if (!quiet) {
|
|
105
|
+
result.value = {
|
|
106
|
+
...result.value,
|
|
107
|
+
state: wasConnected ? "reconnecting" : "connecting",
|
|
108
|
+
attemptNumber: attempt,
|
|
109
|
+
retryCount: attempt,
|
|
110
|
+
countdown: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
98
113
|
const started = Date.now();
|
|
99
114
|
try {
|
|
100
115
|
const resp = await fetch(endpointUrl(), {
|
|
@@ -104,12 +119,26 @@ export function useConnectionProbe(): {
|
|
|
104
119
|
const latencyMs = Date.now() - started;
|
|
105
120
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
106
121
|
consecutiveFailures = 0;
|
|
122
|
+
nextAttemptAt = 0;
|
|
107
123
|
result.value = {
|
|
108
124
|
...result.value,
|
|
109
125
|
connected: true,
|
|
110
126
|
state: "connected",
|
|
111
127
|
latencyMs,
|
|
128
|
+
// The attempt counter is LADDER state, not a lifetime odometer:
|
|
129
|
+
// a healthy probe zeroes it so the next outage's ladder reads
|
|
130
|
+
// "Retrying 1/3" instead of an ever-growing number. Continuous
|
|
131
|
+
// polling made this matter — the counter used to stop moving
|
|
132
|
+
// once connected, so the creep was invisible.
|
|
133
|
+
attemptNumber: 0,
|
|
134
|
+
retryCount: 0,
|
|
135
|
+
countdown: 0,
|
|
112
136
|
};
|
|
137
|
+
// Continuous observation (2026-10-11 auth-surface report): the
|
|
138
|
+
// light must FOLLOW the backend, not just record the initial
|
|
139
|
+
// verdict. Parked-forever-on-success meant a backend dying later
|
|
140
|
+
// left a permanently green light on sessionless surfaces.
|
|
141
|
+
schedulePoll(true);
|
|
113
142
|
} catch {
|
|
114
143
|
consecutiveFailures += 1;
|
|
115
144
|
const failed = consecutiveFailures >= result.value.retryTotal;
|
|
@@ -118,6 +147,8 @@ export function useConnectionProbe(): {
|
|
|
118
147
|
connected: false,
|
|
119
148
|
state: failed ? "failed" : "disconnected",
|
|
120
149
|
latencyMs: null,
|
|
150
|
+
attemptNumber: attempt,
|
|
151
|
+
retryCount: attempt,
|
|
121
152
|
};
|
|
122
153
|
nextAttemptAt = Date.now() + DEFAULT_POLL_MS;
|
|
123
154
|
applyCountdown();
|
|
@@ -125,15 +156,21 @@ export function useConnectionProbe(): {
|
|
|
125
156
|
}
|
|
126
157
|
}
|
|
127
158
|
|
|
128
|
-
function schedulePoll(): void {
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
159
|
+
function schedulePoll(quiet = false): void {
|
|
160
|
+
// The zombie guard: an in-flight probe settling after unmount lands
|
|
161
|
+
// here with nothing left to serve — arming would loop forever on a
|
|
162
|
+
// dead component. Both call sites (success re-check AND the failure
|
|
163
|
+
// ladder) route through this single gate.
|
|
164
|
+
if (disposed) return;
|
|
165
|
+
// Visibility-aware one-shot: the 15s window holds while the page is
|
|
166
|
+
// hidden instead of burning down on a throttled background timer
|
|
167
|
+
// (intervalBus — this is its designed case: data polling, not
|
|
168
|
+
// animation). Retry-ladder hops stay loud; the success-path
|
|
169
|
+
// re-check passes `quiet` so a healthy poll never blinks the light.
|
|
133
170
|
pollHandle?.disconnect();
|
|
134
171
|
pollHandle = scheduleIntervalAfter(() => {
|
|
135
172
|
pollHandle = null;
|
|
136
|
-
void probeOnce();
|
|
173
|
+
void probeOnce({ quiet });
|
|
137
174
|
}, DEFAULT_POLL_MS);
|
|
138
175
|
}
|
|
139
176
|
|
|
@@ -151,6 +188,9 @@ export function useConnectionProbe(): {
|
|
|
151
188
|
onUnmounted(() => {
|
|
152
189
|
liveProbes = Math.max(0, liveProbes - 1);
|
|
153
190
|
probeRuntime?.pulse({ liveProbes });
|
|
191
|
+
// Disarm FIRST: an in-flight probe's late success must find the
|
|
192
|
+
// guard already up when it reaches schedulePoll.
|
|
193
|
+
disposed = true;
|
|
154
194
|
pollHandle?.disconnect();
|
|
155
195
|
pollHandle = null;
|
|
156
196
|
tickHandle?.disconnect();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, type Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import { useEngineHealth, type EngineHealth } from "./useEngineHealth";
|
|
5
|
+
|
|
6
|
+
/** useEngineHealth is mount-scoped (onMounted drives the first fetch);
|
|
7
|
+
* each test rides a minimal component harness. Fake timers own the
|
|
8
|
+
* 30s refresh loop. */
|
|
9
|
+
|
|
10
|
+
interface Deferred {
|
|
11
|
+
promise: Promise<Response>;
|
|
12
|
+
resolve: (r: Response) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function deferredJsonResponse(): Deferred {
|
|
16
|
+
let resolve!: (r: Response) => void;
|
|
17
|
+
const promise = new Promise<Response>((res) => {
|
|
18
|
+
resolve = res;
|
|
19
|
+
});
|
|
20
|
+
return { promise, resolve };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function okBody(body: Record<string, unknown>): Response {
|
|
24
|
+
return { ok: true, json: () => Promise.resolve(body) } as unknown as Response;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mountHealth() {
|
|
28
|
+
let out!: {
|
|
29
|
+
health: Ref<EngineHealth | null>;
|
|
30
|
+
loading: Ref<boolean>;
|
|
31
|
+
error: Ref<string | null>;
|
|
32
|
+
refresh: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
const app = createApp(
|
|
35
|
+
defineComponent({
|
|
36
|
+
setup() {
|
|
37
|
+
out = useEngineHealth();
|
|
38
|
+
return () => h("div");
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
const container = document.createElement("div");
|
|
43
|
+
document.body.appendChild(container);
|
|
44
|
+
app.mount(container);
|
|
45
|
+
return {
|
|
46
|
+
api: () => out,
|
|
47
|
+
unmount: () => {
|
|
48
|
+
app.unmount();
|
|
49
|
+
container.remove();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
document.body.innerHTML = "";
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("useEngineHealth refresh loop", () => {
|
|
61
|
+
it("recovers the engine row after a transient mount-time failure", async () => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
const fetchMock = vi.fn();
|
|
64
|
+
fetchMock.mockRejectedValueOnce(new Error("transient")); // mount: fails
|
|
65
|
+
fetchMock.mockResolvedValueOnce(okBody({ engine_version: "9.8.7", build_hash: "abc1234" }));
|
|
66
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
67
|
+
const { api, unmount } = mountHealth();
|
|
68
|
+
try {
|
|
69
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
70
|
+
expect(api().health.value, "mount-time failure blanks the row").toBeNull();
|
|
71
|
+
expect(api().error.value).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
// The one-shot era left this row dead for the page's whole life;
|
|
74
|
+
// the 30s loop revives it (2026-10-11 auth-surface report).
|
|
75
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
76
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
77
|
+
expect(api().health.value?.engineVersion).toBe("9.8.7");
|
|
78
|
+
expect(api().health.value?.engineBuildHash).toBe("abc1234");
|
|
79
|
+
expect(api().error.value).toBeNull();
|
|
80
|
+
} finally {
|
|
81
|
+
unmount();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps the row fresh on cadence and stops on unmount", async () => {
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
const fetchMock = vi.fn(() => Promise.resolve(okBody({ engine_version: "1.0.0" })));
|
|
88
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
89
|
+
const { unmount } = mountHealth();
|
|
90
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
93
|
+
expect(fetchMock, "cadence refresh fires").toHaveBeenCalledTimes(2);
|
|
94
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
95
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
96
|
+
unmount();
|
|
97
|
+
await vi.advanceTimersByTimeAsync(90_000);
|
|
98
|
+
expect(fetchMock, "no refreshes after unmount").toHaveBeenCalledTimes(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("never stacks requests — a slow refresh skips the next beat", async () => {
|
|
102
|
+
vi.useFakeTimers();
|
|
103
|
+
const slow = deferredJsonResponse();
|
|
104
|
+
const fetchMock = vi.fn();
|
|
105
|
+
fetchMock.mockResolvedValueOnce(okBody({ engine_version: "1.0.0" })); // mount
|
|
106
|
+
fetchMock.mockImplementationOnce(() => slow.promise); // t+30s: hangs
|
|
107
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
108
|
+
const { api, unmount } = mountHealth();
|
|
109
|
+
try {
|
|
110
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
111
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
113
|
+
expect(api().loading.value).toBe(true);
|
|
114
|
+
|
|
115
|
+
// The t+60s beat arrives while the t+30s refresh still hangs — it
|
|
116
|
+
// must be skipped, not stacked.
|
|
117
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
118
|
+
expect(fetchMock, "in-flight refresh holds the beat").toHaveBeenCalledTimes(2);
|
|
119
|
+
|
|
120
|
+
slow.resolve(okBody({ engine_version: "2.0.0" }));
|
|
121
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
122
|
+
expect(api().loading.value).toBe(false);
|
|
123
|
+
expect(api().health.value?.engineVersion).toBe("2.0.0");
|
|
124
|
+
} finally {
|
|
125
|
+
unmount();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { onMounted, ref, type Ref } from "vue";
|
|
1
|
+
import { onMounted, onUnmounted, ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
import { scheduleInterval, type IntervalHandle } from "../runtime/intervalBus";
|
|
2
4
|
|
|
3
5
|
/** Inlined subset of the `/api/health` network payload — kept local so
|
|
4
6
|
* hikari stays dependency-free of protocol packages. */
|
|
@@ -24,9 +26,18 @@ export interface EngineHealth {
|
|
|
24
26
|
network: NetworkInfo | null;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** Mounted refresh cadence (visibility-aware via the interval bus — a
|
|
30
|
+
* hidden tab parks instead of burning radio). 30s: engine identity is
|
|
31
|
+
* not latency-critical, but a transient mount-time failure must not
|
|
32
|
+
* blank the engine row for the page's whole life (2026-10-11
|
|
33
|
+
* auth-surface report — the one-shot fetch left a dead row forever). */
|
|
34
|
+
const REFRESH_MS = 30_000;
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
|
-
* Fetch `/api/health`
|
|
29
|
-
*
|
|
37
|
+
* Fetch `/api/health` on mount and keep it fresh on a visibility-aware
|
|
38
|
+
* 30s loop; expose the engine/network subset. Replaces per-app
|
|
39
|
+
* hand-rolled health fetches. Call `refresh()` to force an immediate
|
|
40
|
+
* re-poll outside the cadence.
|
|
30
41
|
*
|
|
31
42
|
* @param baseUrl Optional API origin (defaults to same-origin "").
|
|
32
43
|
*/
|
|
@@ -39,6 +50,7 @@ export function useEngineHealth(baseUrl?: string): {
|
|
|
39
50
|
const health = ref<EngineHealth | null>(null);
|
|
40
51
|
const loading = ref(false);
|
|
41
52
|
const error = ref<string | null>(null);
|
|
53
|
+
let tickHandle: IntervalHandle | null = null;
|
|
42
54
|
|
|
43
55
|
async function refresh(): Promise<void> {
|
|
44
56
|
loading.value = true;
|
|
@@ -63,7 +75,19 @@ export function useEngineHealth(baseUrl?: string): {
|
|
|
63
75
|
}
|
|
64
76
|
}
|
|
65
77
|
|
|
66
|
-
onMounted(() => {
|
|
78
|
+
onMounted(() => {
|
|
79
|
+
void refresh();
|
|
80
|
+
tickHandle = scheduleInterval(() => {
|
|
81
|
+
// Never stack requests on a slow endpoint — skip the beat while a
|
|
82
|
+
// refresh is still in flight.
|
|
83
|
+
if (!loading.value) void refresh();
|
|
84
|
+
}, REFRESH_MS);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
onUnmounted(() => {
|
|
88
|
+
tickHandle?.disconnect();
|
|
89
|
+
tickHandle = null;
|
|
90
|
+
});
|
|
67
91
|
|
|
68
92
|
return { health, loading, error, refresh };
|
|
69
93
|
}
|