498 lines
14 KiB
TypeScript
498 lines
14 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import {
|
|
attachDashboardRefreshLifecycle,
|
|
collectVisibleDashboardModelIds,
|
|
createDashboardPerformanceMarks,
|
|
createDashboardRefreshDelay,
|
|
dashboardPerformanceMarks,
|
|
createDashboardTileSnapshotStore,
|
|
createDashboardTileBackoff,
|
|
createDashboardRequestAborter,
|
|
runDashboardHydrationQueue,
|
|
runDashboardHydrationBatchQueue,
|
|
runViewportAwareDashboardHydrationQueue,
|
|
shouldPauseDashboardRefresh,
|
|
splitDashboardHydrationItemsByVisibility,
|
|
waitForDashboardHydrationIdle,
|
|
type DashboardIntersectionEntry,
|
|
} from "./dashboard-refresh";
|
|
|
|
describe("dashboard refresh lifecycle", () => {
|
|
test("pauses refreshes when the document is hidden or the browser is offline", () => {
|
|
expect(
|
|
shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }),
|
|
).toBe(false);
|
|
expect(
|
|
shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }),
|
|
).toBe(true);
|
|
expect(
|
|
shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("aborts shell and tile requests when a new shell run starts", () => {
|
|
const aborter = createDashboardRequestAborter();
|
|
const shellSignal = aborter.beginShellRun();
|
|
const tileSignal = aborter.beginTileRun();
|
|
|
|
const nextShellSignal = aborter.beginShellRun();
|
|
|
|
expect(shellSignal.aborted).toBe(true);
|
|
expect(tileSignal.aborted).toBe(true);
|
|
expect(nextShellSignal.aborted).toBe(false);
|
|
});
|
|
|
|
test("aborts active requests when refreshes are paused", () => {
|
|
const aborter = createDashboardRequestAborter();
|
|
const shellSignal = aborter.beginShellRun();
|
|
const tileSignal = aborter.beginTileRun();
|
|
|
|
aborter.abortActiveRequests();
|
|
|
|
expect(shellSignal.aborted).toBe(true);
|
|
expect(tileSignal.aborted).toBe(true);
|
|
});
|
|
|
|
test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => {
|
|
const documentTarget = new EventTarget();
|
|
const windowTarget = new EventTarget();
|
|
const calls: string[] = [];
|
|
let paused = true;
|
|
|
|
const detach = attachDashboardRefreshLifecycle({
|
|
documentTarget,
|
|
windowTarget,
|
|
loadDashboard: () => {
|
|
calls.push("load");
|
|
},
|
|
pauseRefreshes: () => {
|
|
calls.push("pause");
|
|
},
|
|
refreshPaused: () => paused,
|
|
});
|
|
|
|
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
windowTarget.dispatchEvent(new Event("offline"));
|
|
windowTarget.dispatchEvent(new Event("pagehide"));
|
|
|
|
paused = false;
|
|
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
windowTarget.dispatchEvent(new Event("online"));
|
|
|
|
detach();
|
|
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
windowTarget.dispatchEvent(new Event("online"));
|
|
|
|
expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]);
|
|
});
|
|
|
|
test("limits tile hydration concurrency", async () => {
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const started: number[] = [];
|
|
const releases = new Map<number, () => void>();
|
|
|
|
const queue = runDashboardHydrationQueue({
|
|
concurrency: 2,
|
|
items: [1, 2, 3, 4],
|
|
signal: new AbortController().signal,
|
|
hydrate: async (item) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
started.push(item);
|
|
await new Promise<void>((resolve) => releases.set(item, resolve));
|
|
active -= 1;
|
|
},
|
|
});
|
|
|
|
await waitFor(() => started.length === 2);
|
|
expect(started).toEqual([1, 2]);
|
|
expect(maxActive).toBe(2);
|
|
|
|
releases.get(1)?.();
|
|
await waitFor(() => started.length === 3);
|
|
expect(started).toEqual([1, 2, 3]);
|
|
expect(maxActive).toBe(2);
|
|
|
|
releases.get(2)?.();
|
|
releases.get(3)?.();
|
|
await waitFor(() => started.length === 4);
|
|
releases.get(4)?.();
|
|
await queue;
|
|
expect(maxActive).toBe(2);
|
|
});
|
|
|
|
test("hydrates queued work in fixed-size batches", async () => {
|
|
const batches: number[][] = [];
|
|
|
|
await runDashboardHydrationBatchQueue({
|
|
batchSize: 3,
|
|
hydrateBatch: (items) => {
|
|
batches.push(items);
|
|
},
|
|
items: [1, 2, 3, 4, 5, 6, 7],
|
|
signal: new AbortController().signal,
|
|
});
|
|
|
|
expect(batches).toEqual([
|
|
[1, 2, 3],
|
|
[4, 5, 6],
|
|
[7],
|
|
]);
|
|
});
|
|
|
|
test("hydrates visible items before deferred items and waits for idle", async () => {
|
|
const calls: string[] = [];
|
|
const idleReleases: Array<() => void> = [];
|
|
|
|
const hydration = runViewportAwareDashboardHydrationQueue({
|
|
collectVisibleModelIds: async () => new Set(["b"]),
|
|
concurrency: 1,
|
|
getModelId: (item) => item,
|
|
hydrate: async (item) => {
|
|
calls.push(item);
|
|
},
|
|
items: ["a", "b", "c"],
|
|
onAllItemsSettled: () => calls.push("all-settled"),
|
|
onVisibleItemsSettled: () => calls.push("visible-settled"),
|
|
signal: new AbortController().signal,
|
|
waitForIdle: async () => {
|
|
calls.push("idle");
|
|
await new Promise<void>((resolve) => idleReleases.push(resolve));
|
|
},
|
|
});
|
|
|
|
await waitFor(() => calls.includes("idle"));
|
|
expect(calls).toEqual(["b", "visible-settled", "idle"]);
|
|
|
|
idleReleases.shift()?.();
|
|
await hydration;
|
|
expect(calls).toEqual([
|
|
"b",
|
|
"visible-settled",
|
|
"idle",
|
|
"a",
|
|
"c",
|
|
"all-settled",
|
|
]);
|
|
});
|
|
|
|
test("splits visible hydration items while preserving document order", () => {
|
|
expect(splitDashboardHydrationItemsByVisibility({
|
|
getModelId: (item) => item.id,
|
|
items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }],
|
|
visibleModelIds: new Set(["service", "status"]),
|
|
})).toEqual({
|
|
visible: [{ id: "status" }, { id: "service" }],
|
|
deferred: [{ id: "telemetry" }],
|
|
});
|
|
});
|
|
|
|
test("collects visible data-model-id elements with IntersectionObserver", async () => {
|
|
const elements = [
|
|
modelElement("status"),
|
|
modelElement("telemetry"),
|
|
modelElement("unrelated"),
|
|
];
|
|
let callback:
|
|
| ((entries: DashboardIntersectionEntry[]) => void)
|
|
| undefined;
|
|
let finishObservation: (() => void) | undefined;
|
|
let disconnected = false;
|
|
const observed: string[] = [];
|
|
|
|
const visible = collectVisibleDashboardModelIds({
|
|
createObserver: (observerCallback) => {
|
|
callback = observerCallback;
|
|
return {
|
|
disconnect() {
|
|
disconnected = true;
|
|
},
|
|
observe(element) {
|
|
observed.push(element.getAttribute("data-model-id") || "");
|
|
},
|
|
};
|
|
},
|
|
documentTarget: {
|
|
querySelectorAll: () => elements,
|
|
},
|
|
modelIds: ["status", "telemetry"],
|
|
setTimeout: (handler) => {
|
|
finishObservation = handler;
|
|
return 1 as unknown as ReturnType<typeof setTimeout>;
|
|
},
|
|
clearTimeout: () => undefined,
|
|
signal: new AbortController().signal,
|
|
});
|
|
|
|
callback?.([
|
|
{
|
|
isIntersecting: false,
|
|
intersectionRatio: 0,
|
|
target: elements[0],
|
|
},
|
|
{
|
|
isIntersecting: true,
|
|
target: elements[1],
|
|
},
|
|
]);
|
|
finishObservation?.();
|
|
|
|
expect(await visible).toEqual(new Set(["telemetry"]));
|
|
expect(observed).toEqual(["status", "telemetry"]);
|
|
expect(disconnected).toBe(true);
|
|
});
|
|
|
|
test("waits for requestIdleCallback when available", async () => {
|
|
let idleCallback: (() => void) | undefined;
|
|
let cancelledIdle: number | undefined;
|
|
const wait = waitForDashboardHydrationIdle({
|
|
cancelIdleCallback: (handle) => {
|
|
cancelledIdle = handle;
|
|
},
|
|
requestIdleCallback: (callback) => {
|
|
idleCallback = callback;
|
|
return 7;
|
|
},
|
|
signal: new AbortController().signal,
|
|
});
|
|
|
|
expect(cancelledIdle).toBeUndefined();
|
|
idleCallback?.();
|
|
await wait;
|
|
expect(cancelledIdle).toBe(7);
|
|
});
|
|
|
|
test("jitters refresh delays and slows repeated failures", () => {
|
|
const delay = createDashboardRefreshDelay({
|
|
jitterRatio: 0.1,
|
|
random: () => 1,
|
|
});
|
|
|
|
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
|
delay.recordFailure();
|
|
expect(delay.nextDelayMs(1_000)).toBe(2_200);
|
|
delay.recordFailure();
|
|
expect(delay.nextDelayMs(1_000)).toBe(4_400);
|
|
delay.recordSuccess();
|
|
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
|
});
|
|
|
|
test("marks dashboard performance milestones once per shell run", () => {
|
|
const marks: string[] = [];
|
|
const performanceMarks = createDashboardPerformanceMarks({
|
|
mark: (name) => marks.push(name),
|
|
});
|
|
|
|
performanceMarks.markShellLoad();
|
|
performanceMarks.markFirstTileReady();
|
|
performanceMarks.markFirstTileReady();
|
|
performanceMarks.markVisibleTilesReady();
|
|
performanceMarks.markAllTilesSettled();
|
|
performanceMarks.markShellLoad();
|
|
performanceMarks.markFirstTileReady();
|
|
|
|
expect(marks).toEqual([
|
|
dashboardPerformanceMarks.shellLoad,
|
|
dashboardPerformanceMarks.firstTileReady,
|
|
dashboardPerformanceMarks.visibleTilesReady,
|
|
dashboardPerformanceMarks.allTilesSettled,
|
|
dashboardPerformanceMarks.shellLoad,
|
|
dashboardPerformanceMarks.firstTileReady,
|
|
]);
|
|
});
|
|
|
|
test("backs off failed tile keys and resets after success", () => {
|
|
const backoff = createDashboardTileBackoff();
|
|
|
|
backoff.recordFailure("telemetry:infra-ram", 1_000);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 15_999)).toBe(false);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 16_000)).toBe(true);
|
|
|
|
backoff.recordFailure("telemetry:infra-ram", 16_000);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 45_999)).toBe(false);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 46_000)).toBe(true);
|
|
|
|
backoff.recordFailure("telemetry:infra-ram", 46_000);
|
|
backoff.recordFailure("telemetry:infra-ram", 106_000);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 225_999)).toBe(false);
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 226_000)).toBe(true);
|
|
|
|
backoff.recordSuccess("telemetry:infra-ram");
|
|
expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true);
|
|
});
|
|
|
|
test("stores only ready tile snapshots and restores them for matching revisions", () => {
|
|
const storage = createMemoryStorage();
|
|
let now = 1_000;
|
|
const store = createDashboardTileSnapshotStore(storage, {
|
|
now: () => now,
|
|
});
|
|
|
|
store.saveReadyTile({
|
|
currentRevisionId: "revision-a",
|
|
response: {
|
|
state: "ready",
|
|
tile: { kind: "telemetry", id: "infra-ram" },
|
|
item: {
|
|
id: "infra-ram",
|
|
label: "Infra RAM",
|
|
value: { kind: "percent", value: 42 },
|
|
detail: "live",
|
|
severity: "ok",
|
|
},
|
|
},
|
|
schemaVersion: "dashboard.v1",
|
|
});
|
|
store.saveTile({
|
|
currentRevisionId: "revision-a",
|
|
response: {
|
|
state: "not_found",
|
|
tile: { kind: "telemetry", id: "missing" },
|
|
message: "Missing",
|
|
},
|
|
schemaVersion: "dashboard.v1",
|
|
});
|
|
store.saveTile({
|
|
currentRevisionId: "revision-a",
|
|
response: {
|
|
state: "disabled",
|
|
tile: { kind: "module", id: "disabled-module" },
|
|
message: "Disabled",
|
|
},
|
|
schemaVersion: "dashboard.v1",
|
|
});
|
|
|
|
now = 61_000;
|
|
|
|
const restored = store.restore({
|
|
currentRevisionId: "revision-a",
|
|
schemaVersion: "dashboard.v1",
|
|
});
|
|
|
|
expect(restored).toEqual([
|
|
{
|
|
ageMs: 60_000,
|
|
response: {
|
|
state: "ready",
|
|
tile: { kind: "telemetry", id: "infra-ram" },
|
|
item: {
|
|
id: "infra-ram",
|
|
label: "Infra RAM",
|
|
value: { kind: "percent", value: 42 },
|
|
detail: "live - stale 60s",
|
|
severity: "stale",
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
expect(store.restore({
|
|
currentRevisionId: "revision-b",
|
|
schemaVersion: "dashboard.v1",
|
|
})).toEqual([]);
|
|
});
|
|
|
|
test("ignores malformed stored tile snapshots", () => {
|
|
const storage = createMemoryStorage();
|
|
storage.setItem(
|
|
"dimensionlab.dashboard.tiles.v1",
|
|
JSON.stringify({
|
|
currentRevisionId: "revision-a",
|
|
schemaVersion: "dashboard.v1",
|
|
tiles: [
|
|
{},
|
|
{
|
|
item: {
|
|
id: "incomplete",
|
|
detail: "live",
|
|
},
|
|
savedAt: 1_000,
|
|
tile: { kind: "telemetry", id: "incomplete" },
|
|
},
|
|
{
|
|
item: {
|
|
id: "infra-ram",
|
|
label: "Infra RAM",
|
|
value: { kind: "percent", value: 42 },
|
|
detail: "live",
|
|
severity: "ok",
|
|
},
|
|
savedAt: 1_000,
|
|
tile: { kind: "telemetry", id: "infra-ram" },
|
|
},
|
|
{
|
|
item: { id: "broken-detail", detail: 42 },
|
|
savedAt: 1_000,
|
|
tile: { kind: "telemetry", id: "broken-detail" },
|
|
},
|
|
],
|
|
version: 1,
|
|
}),
|
|
);
|
|
|
|
const store = createDashboardTileSnapshotStore(storage, {
|
|
now: () => 16_000,
|
|
});
|
|
|
|
expect(store.restore({
|
|
currentRevisionId: "revision-a",
|
|
schemaVersion: "dashboard.v1",
|
|
})).toEqual([
|
|
{
|
|
ageMs: 15_000,
|
|
response: {
|
|
state: "ready",
|
|
tile: { kind: "telemetry", id: "infra-ram" },
|
|
item: {
|
|
id: "infra-ram",
|
|
label: "Infra RAM",
|
|
value: { kind: "percent", value: 42 },
|
|
detail: "live - stale 15s",
|
|
severity: "stale",
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
async function waitFor(predicate: () => boolean) {
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
if (predicate()) return;
|
|
await Promise.resolve();
|
|
}
|
|
|
|
throw new Error("condition was not met");
|
|
}
|
|
|
|
function createMemoryStorage(): Storage {
|
|
const values = new Map<string, string>();
|
|
return {
|
|
get length() {
|
|
return values.size;
|
|
},
|
|
clear() {
|
|
values.clear();
|
|
},
|
|
getItem(key) {
|
|
return values.get(key) ?? null;
|
|
},
|
|
key(index) {
|
|
return [...values.keys()][index] ?? null;
|
|
},
|
|
removeItem(key) {
|
|
values.delete(key);
|
|
},
|
|
setItem(key, value) {
|
|
values.set(key, value);
|
|
},
|
|
};
|
|
}
|
|
|
|
function modelElement(modelId: string) {
|
|
return {
|
|
getAttribute(name: string) {
|
|
return name === "data-model-id" ? modelId : null;
|
|
},
|
|
};
|
|
}
|