test: require ui deferred callbacks

This commit is contained in:
Peter Steinberger
2026-05-08 19:15:33 +01:00
parent 076526b5c0
commit d642cce5ae
4 changed files with 65 additions and 64 deletions

View File

@@ -133,12 +133,15 @@ function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessio
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
let resolve: ((value: T) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}

View File

@@ -41,6 +41,17 @@ vi.mock("./app-scroll.ts", () => ({
import { handleConnected } from "./app-lifecycle.ts";
function createDeferred() {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected bootstrap deferred resolver to be initialized");
}
return { promise, resolve };
}
function createHost() {
return {
basePath: "",
@@ -77,30 +88,22 @@ describe("handleConnected", () => {
});
it("waits for bootstrap load before first gateway connect", async () => {
let resolveBootstrap!: () => void;
loadBootstrapMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveBootstrap = resolve;
}),
);
const bootstrap = createDeferred();
loadBootstrapMock.mockReturnValueOnce(bootstrap.promise);
connectGatewayMock.mockReset();
const host = createHost();
handleConnected(host as never);
expect(connectGatewayMock).not.toHaveBeenCalled();
resolveBootstrap();
bootstrap.resolve();
await Promise.resolve();
expect(connectGatewayMock).toHaveBeenCalledTimes(1);
});
it("skips deferred connect when disconnected before bootstrap resolves", async () => {
let resolveBootstrap!: () => void;
loadBootstrapMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveBootstrap = resolve;
}),
);
const bootstrap = createDeferred();
loadBootstrapMock.mockReturnValueOnce(bootstrap.promise);
connectGatewayMock.mockReset();
const host = createHost();
@@ -108,7 +111,7 @@ describe("handleConnected", () => {
expect(connectGatewayMock).not.toHaveBeenCalled();
host.connectGeneration += 1;
resolveBootstrap();
bootstrap.resolve();
await Promise.resolve();
expect(connectGatewayMock).not.toHaveBeenCalled();

View File

@@ -3,6 +3,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
type CronRunsLoadStatus = "ok" | "error" | "skipped";
function createDeferred<T = void>() {
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
const promise = new Promise<T>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected deferred resolver to be initialized");
}
return { promise, resolve };
}
const mocks = vi.hoisted(() => ({
refreshChatMock: vi.fn(async () => {}),
scheduleChatScrollMock: vi.fn(),
@@ -196,12 +207,8 @@ describe("refreshActiveTab", () => {
it("records tab visible timing without waiting for the tab refresh RPC", async () => {
const host = createHost();
host.tab = "chat";
let resolveSessions!: () => void;
mocks.loadSessionsMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSessions = resolve;
}),
);
const sessions = createDeferred();
mocks.loadSessionsMock.mockReturnValueOnce(sessions.promise);
setTab(host as never, "sessions");
@@ -221,7 +228,7 @@ describe("refreshActiveTab", () => {
);
});
resolveSessions();
sessions.resolve();
});
it("does not wait for secondary overview refreshes before resolving", async () => {
@@ -244,12 +251,8 @@ describe("refreshActiveTab", () => {
it("does not wait for config schema before resolving config tab refresh", async () => {
const host = createHost();
host.tab = "config";
let resolveSchema!: () => void;
mocks.loadConfigSchemaMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSchema = resolve;
}),
);
const schema = createDeferred();
mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise);
const refresh = refreshActiveTab(host as never);
const outcome = await Promise.race([
@@ -262,7 +265,7 @@ describe("refreshActiveTab", () => {
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
expect(host.requestUpdate).not.toHaveBeenCalled();
resolveSchema();
schema.resolve();
await vi.waitFor(() => {
expect(host.requestUpdate).toHaveBeenCalledOnce();
@@ -272,18 +275,12 @@ describe("refreshActiveTab", () => {
it("renders channels from the cheap snapshot before starting slow probes", async () => {
const host = createHost();
host.tab = "channels";
let resolveSchema!: () => void;
let resolveProbe!: () => void;
mocks.loadConfigSchemaMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveSchema = resolve;
}),
);
const schema = createDeferred();
const channelProbe = createDeferred();
mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise);
mocks.loadChannelsMock.mockImplementation(async (_host, probe) => {
if (probe) {
await new Promise<void>((resolve) => {
resolveProbe = resolve;
});
await channelProbe.promise;
}
});
@@ -298,8 +295,8 @@ describe("refreshActiveTab", () => {
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
expect(host.requestUpdate).not.toHaveBeenCalled();
resolveSchema();
resolveProbe();
schema.resolve();
channelProbe.resolve();
await vi.waitFor(() => {
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
@@ -309,16 +306,12 @@ describe("refreshActiveTab", () => {
it("records overview secondary refresh duration and aggregate status", async () => {
const host = createHost();
host.tab = "overview";
let resolveUsage!: () => void;
mocks.loadUsageMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
resolveUsage = resolve;
}),
);
const usage = createDeferred();
mocks.loadUsageMock.mockReturnValueOnce(usage.promise);
mocks.loadSkillsMock.mockRejectedValueOnce(new Error("skills failed"));
await refreshActiveTab(host as never);
resolveUsage();
usage.resolve();
await vi.waitFor(() => {
expect(host.eventLogBuffer).toEqual(
@@ -401,16 +394,12 @@ describe("refreshActiveTab", () => {
it("does not record stale cron run timing after leaving the cron tab", async () => {
const host = createHost();
host.tab = "cron";
let resolveRuns!: () => void;
mocks.loadCronRunsMock.mockReturnValueOnce(
new Promise<"ok">((resolve) => {
resolveRuns = () => resolve("ok");
}),
);
const runs = createDeferred<"ok">();
mocks.loadCronRunsMock.mockReturnValueOnce(runs.promise);
await refreshActiveTab(host as never);
host.tab = "chat";
resolveRuns();
runs.resolve("ok");
await Promise.resolve();
expect(host.eventLogBuffer).not.toEqual(

View File

@@ -27,6 +27,17 @@ type HandlerMap = {
type MockWebSocketHandler = (ev?: { code?: number; data?: string; reason?: string }) => void;
function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
const promise = new Promise<T>((res) => {
resolve = res;
});
if (!resolve) {
throw new Error("Expected deferred resolver to be initialized");
}
return { promise, resolve };
}
class MockWebSocket {
static OPEN = 1;
@@ -556,13 +567,8 @@ describe("GatewayBrowserClient", () => {
it("does not send stale connect frames on a replacement socket", async () => {
vi.useFakeTimers();
let resolveIdentity!: (identity: DeviceIdentity) => void;
loadOrCreateDeviceIdentityMock.mockImplementationOnce(
() =>
new Promise<DeviceIdentity>((resolve) => {
resolveIdentity = resolve;
}),
);
const identity = createDeferred<DeviceIdentity>();
loadOrCreateDeviceIdentityMock.mockImplementationOnce(() => identity.promise);
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
@@ -585,7 +591,7 @@ describe("GatewayBrowserClient", () => {
const secondWs = getLatestWebSocket();
expect(secondWs).not.toBe(firstWs);
resolveIdentity({
identity.resolve({
deviceId: "device-1",
privateKey: "private-key", // pragma: allowlist secret
publicKey: "public-key", // pragma: allowlist secret