feat(KB-662): add visibility-based refresh to dashboard hooks
This commit is contained in:
60
packages/dashboard/app/components/mission-types.ts
Normal file
60
packages/dashboard/app/components/mission-types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Local type definitions for MissionManager
|
||||
|
||||
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
|
||||
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
|
||||
export type SliceStatus = "pending" | "active" | "complete";
|
||||
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
|
||||
|
||||
export interface Mission {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: MissionStatus;
|
||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||
autoAdvance?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MissionFeature {
|
||||
id: string;
|
||||
sliceId: string;
|
||||
taskId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
acceptanceCriteria?: string;
|
||||
status: FeatureStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Slice {
|
||||
id: string;
|
||||
milestoneId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: SliceStatus;
|
||||
orderIndex: number;
|
||||
activatedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
features: MissionFeature[];
|
||||
}
|
||||
|
||||
export interface Milestone {
|
||||
id: string;
|
||||
missionId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: MilestoneStatus;
|
||||
orderIndex: number;
|
||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||
dependencies: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
slices: Slice[];
|
||||
}
|
||||
|
||||
export interface MissionWithHierarchy extends Mission {
|
||||
milestones: Milestone[];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useActivityLog } from "../useActivityLog";
|
||||
import type { ActivityFeedEntry } from "../../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("useActivityLog visibility change", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValueOnce(mockFetchResponse(true, initialEntries));
|
||||
|
||||
renderHook(() => useActivityLog());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
setVisibilityState("hidden");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
59
packages/dashboard/app/hooks/__tests__/useUsageData.test.ts
Normal file
59
packages/dashboard/app/hooks/__tests__/useUsageData.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useUsageData } from "../useUsageData";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchUsageData = vi.mocked(api.fetchUsageData);
|
||||
|
||||
describe("useUsageData visibility change", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchUsageData.mockReset();
|
||||
// Set default visibility state to visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (document as any).visibilityState;
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialData = {
|
||||
providers: [{ name: "Claude", icon: "🟠", status: "ok" as const, windows: [] }],
|
||||
};
|
||||
mockFetchUsageData.mockResolvedValueOnce(initialData);
|
||||
|
||||
renderHook(() => useUsageData({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchUsageData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockFetchUsageData.mockClear();
|
||||
|
||||
setVisibilityState("hidden");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(mockFetchUsageData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
31
packages/dashboard/src/server-static-assets.test.ts
Normal file
31
packages/dashboard/src/server-static-assets.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("static asset serving", () => {
|
||||
it("returns 404 for missing asset paths instead of falling back to index.html", async () => {
|
||||
const app = express();
|
||||
|
||||
app.use(express.static("packages/dashboard/dist/client", { index: false }));
|
||||
|
||||
app.get(/^(?!\/assets\/).*/, (_req, res) => {
|
||||
res.sendFile("index.html", { root: "packages/dashboard/dist/client" });
|
||||
});
|
||||
|
||||
const server = await new Promise<import("node:http").Server>((resolve) => {
|
||||
const s = app.listen(0, () => resolve(s));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Failed to get test server port");
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${address.port}/assets/does-not-exist.js`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user