Share lifecycle and async test helpers

This commit is contained in:
murashit 2026-06-02 11:43:06 +09:00
parent 422304aa2b
commit ea00f9a7e9
11 changed files with 118 additions and 144 deletions

View file

@ -1,3 +1,11 @@
import {
transitionConnectionWorkLifecycle,
type ActiveConnectionWork,
type ConnectionWorkLifecycleEvent,
type ConnectionWorkLifecycleState,
} from "../../shared/lifecycle/connection-work";
import { DeferredTask, type DeferredTaskWindow } from "../../shared/lifecycle/deferred-task";
export interface ChatViewRenderScheduleOptions {
forceSlots?: boolean;
}
@ -12,12 +20,9 @@ export type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; th
export type ActiveChatResume = Extract<ChatResumeLifecycleState, { kind: "resuming" }>;
export type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" };
export type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveChatConnection = Extract<ChatConnectionLifecycleState, { kind: "connecting" }>;
export type ChatConnectionLifecycleEvent =
| { type: "started"; connection: ActiveChatConnection }
| { type: "finished"; connection: ActiveChatConnection; promise: Promise<void> }
| { type: "invalidated" };
export type ChatConnectionLifecycleState = ConnectionWorkLifecycleState;
export type ActiveChatConnection = ActiveConnectionWork;
export type ChatConnectionLifecycleEvent = ConnectionWorkLifecycleEvent;
export type RestoredThreadLifecycleState =
| { kind: "idle" }
@ -30,75 +35,56 @@ export type RestoredThreadLifecycleEvent =
| { type: "loading-finished"; loading: Promise<void> }
| { type: "cleared" };
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
export class ChatViewDeferredTasks {
private restoredThreadHydrationTimer: number | null = null;
private renderTimer: number | null = null;
private readonly restoredThreadHydrationTask: DeferredTask;
private readonly renderTask: DeferredTask;
private readonly diagnosticsTask: DeferredTask;
private readonly appServerWarmupTask: DeferredTask;
private renderForceSlots = false;
private diagnosticsTimer: number | null = null;
private appServerWarmupTimer: number | null = null;
constructor(private readonly getWindow: () => TimerWindow) {}
constructor(getWindow: () => DeferredTaskWindow) {
this.renderTask = new DeferredTask(getWindow, 50);
this.diagnosticsTask = new DeferredTask(getWindow, 1_000);
this.restoredThreadHydrationTask = new DeferredTask(getWindow, 1_500);
this.appServerWarmupTask = new DeferredTask(getWindow, 0);
}
scheduleRender(callback: (options: ChatViewRenderScheduleOptions) => void, options: ChatViewRenderScheduleOptions = {}): void {
this.renderForceSlots ||= options.forceSlots ?? false;
if (this.renderTimer !== null) return;
this.renderTimer = this.getWindow().setTimeout(() => {
this.renderTask.schedule(() => {
const forceSlots = this.renderForceSlots;
this.renderTimer = null;
this.renderForceSlots = false;
callback({ forceSlots });
}, 50);
});
}
clearRender(): void {
if (this.renderTimer === null) return;
this.getWindow().clearTimeout(this.renderTimer);
this.renderTimer = null;
this.renderTask.clear();
this.renderForceSlots = false;
}
scheduleDiagnostics(callback: () => void): void {
if (this.diagnosticsTimer !== null) return;
this.diagnosticsTimer = this.getWindow().setTimeout(() => {
this.diagnosticsTimer = null;
callback();
}, 1_000);
this.diagnosticsTask.schedule(callback);
}
clearDiagnostics(): void {
if (this.diagnosticsTimer === null) return;
this.getWindow().clearTimeout(this.diagnosticsTimer);
this.diagnosticsTimer = null;
this.diagnosticsTask.clear();
}
scheduleRestoredThreadHydration(callback: () => void): void {
if (this.restoredThreadHydrationTimer !== null) return;
this.restoredThreadHydrationTimer = this.getWindow().setTimeout(() => {
this.restoredThreadHydrationTimer = null;
callback();
}, 1_500);
this.restoredThreadHydrationTask.schedule(callback);
}
clearRestoredThreadHydration(): void {
if (this.restoredThreadHydrationTimer === null) return;
this.getWindow().clearTimeout(this.restoredThreadHydrationTimer);
this.restoredThreadHydrationTimer = null;
this.restoredThreadHydrationTask.clear();
}
scheduleAppServerWarmup(callback: () => void): void {
if (this.appServerWarmupTimer !== null) return;
this.appServerWarmupTimer = this.getWindow().setTimeout(() => {
this.appServerWarmupTimer = null;
callback();
}, 0);
this.appServerWarmupTask.schedule(callback);
}
clearAppServerWarmup(): void {
if (this.appServerWarmupTimer === null) return;
this.getWindow().clearTimeout(this.appServerWarmupTimer);
this.appServerWarmupTimer = null;
this.appServerWarmupTask.clear();
}
clearAll(): void {
@ -161,14 +147,7 @@ export function transitionChatConnectionLifecycle(
state: ChatConnectionLifecycleState,
event: ChatConnectionLifecycleEvent,
): ChatConnectionLifecycleState {
switch (event.type) {
case "started":
return event.connection;
case "finished":
return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state;
case "invalidated":
return state.kind === "idle" ? state : { kind: "idle" };
}
return transitionConnectionWorkLifecycle(state, event);
}
export function transitionChatResumeLifecycle(state: ChatResumeLifecycleState, event: ChatResumeLifecycleEvent): ChatResumeLifecycleState {

View file

@ -1,4 +1,10 @@
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
import {
transitionConnectionWorkLifecycle,
type ActiveConnectionWork,
type ConnectionWorkLifecycleEvent,
type ConnectionWorkLifecycleState,
} from "../../shared/lifecycle/connection-work";
import { DeferredTask, type DeferredTaskWindow } from "../../shared/lifecycle/deferred-task";
export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" };
export type ActiveThreadsViewRefresh = Extract<ThreadsViewRefreshLifecycleState, { kind: "loading" }>;
@ -7,33 +13,25 @@ export type ThreadsViewRefreshLifecycleEvent =
| { type: "finished"; refresh: ActiveThreadsViewRefresh }
| { type: "invalidated" };
export type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveThreadsViewConnection = Extract<ThreadsViewConnectionLifecycleState, { kind: "connecting" }>;
export type ThreadsViewConnectionLifecycleEvent =
| { type: "started"; connection: ActiveThreadsViewConnection }
| { type: "finished"; connection: ActiveThreadsViewConnection; promise: Promise<void> }
| { type: "invalidated" };
export type ThreadsViewConnectionLifecycleState = ConnectionWorkLifecycleState;
export type ActiveThreadsViewConnection = ActiveConnectionWork;
export type ThreadsViewConnectionLifecycleEvent = ConnectionWorkLifecycleEvent;
export class ThreadsViewDeferredTasks {
private renderTimer: ReturnType<TimerWindow["setTimeout"]> | null = null;
private refreshTimer: ReturnType<TimerWindow["setTimeout"]> | null = null;
private readonly renderTask: DeferredTask;
private readonly refreshTask: DeferredTask;
constructor(private readonly getWindow: () => TimerWindow) {}
constructor(getWindow: () => DeferredTaskWindow) {
this.renderTask = new DeferredTask(getWindow, 0);
this.refreshTask = new DeferredTask(getWindow, 250);
}
scheduleRender(callback: () => void): void {
if (this.renderTimer !== null) return;
this.renderTimer = this.getWindow().setTimeout(() => {
this.renderTimer = null;
callback();
}, 0);
this.renderTask.schedule(callback);
}
scheduleRefresh(callback: () => void): void {
if (this.refreshTimer !== null) return;
this.refreshTimer = this.getWindow().setTimeout(() => {
this.refreshTimer = null;
callback();
}, 250);
this.refreshTask.schedule(callback);
}
clearAll(): void {
@ -42,15 +40,11 @@ export class ThreadsViewDeferredTasks {
}
private clearRender(): void {
if (this.renderTimer === null) return;
this.getWindow().clearTimeout(this.renderTimer);
this.renderTimer = null;
this.renderTask.clear();
}
private clearRefresh(): void {
if (this.refreshTimer === null) return;
this.getWindow().clearTimeout(this.refreshTimer);
this.refreshTimer = null;
this.refreshTask.clear();
}
}
@ -72,12 +66,5 @@ export function transitionThreadsViewConnectionLifecycle(
state: ThreadsViewConnectionLifecycleState,
event: ThreadsViewConnectionLifecycleEvent,
): ThreadsViewConnectionLifecycleState {
switch (event.type) {
case "started":
return event.connection;
case "finished":
return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state;
case "invalidated":
return state.kind === "idle" ? state : { kind: "idle" };
}
return transitionConnectionWorkLifecycle(state, event);
}

View file

@ -0,0 +1,20 @@
export type ConnectionWorkLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveConnectionWork = Extract<ConnectionWorkLifecycleState, { kind: "connecting" }>;
export type ConnectionWorkLifecycleEvent =
| { type: "started"; connection: ActiveConnectionWork }
| { type: "finished"; connection: ActiveConnectionWork; promise: Promise<void> }
| { type: "invalidated" };
export function transitionConnectionWorkLifecycle(
state: ConnectionWorkLifecycleState,
event: ConnectionWorkLifecycleEvent,
): ConnectionWorkLifecycleState {
switch (event.type) {
case "started":
return event.connection;
case "finished":
return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state;
case "invalidated":
return state.kind === "idle" ? state : { kind: "idle" };
}
}

View file

@ -0,0 +1,24 @@
export type DeferredTaskWindow = Pick<Window, "setTimeout" | "clearTimeout">;
export class DeferredTask {
private timer: ReturnType<DeferredTaskWindow["setTimeout"]> | null = null;
constructor(
private readonly getWindow: () => DeferredTaskWindow,
private readonly delay: number,
) {}
schedule(callback: () => void): void {
if (this.timer !== null) return;
this.timer = this.getWindow().setTimeout(() => {
this.timer = null;
callback();
}, this.delay);
}
clear(): void {
if (this.timer === null) return;
this.getWindow().clearTimeout(this.timer);
this.timer = null;
}
}

View file

@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
import { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
import { ChatViewDeferredTasks } from "../../../../../src/features/chat/view-lifecycle";
import { deferred } from "../../../../support/async";
describe("RestoredThreadController", () => {
it("restores a placeholder and schedules deferred hydration", () => {
@ -84,11 +85,3 @@ function restoredThreadState(overrides: Partial<ThreadLifecycleStatePort> = {}):
...overrides,
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}

View file

@ -5,6 +5,7 @@ import { ThreadHistoryLoader } from "../../../src/features/chat/thread-history";
import type { AppServerClient } from "../../../src/app-server/client";
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import { deferred } from "../../support/async";
describe("ThreadHistoryLoader", () => {
it("keeps the latest history load when an older request resolves later", async () => {
@ -88,13 +89,3 @@ function turnFixture(items: ThreadItem[]): Turn {
function assistantMessage(id: string, text: string): ThreadItem {
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}

View file

@ -7,6 +7,7 @@ import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { deferred } from "../../support/async";
describe("ThreadRenameController", () => {
it("rerenders after updating a controlled rename draft", () => {
@ -164,16 +165,6 @@ function assistantMessage(id: string, text: string): ThreadItem {
return { type: "agentMessage", id, text, phase: null, memoryCitation: null };
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();

View file

@ -9,7 +9,7 @@ import { createChatState, type ChatState } from "../../../src/features/chat/chat
import { composerSlotSnapshot } from "../../../src/features/chat/view-snapshot";
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
import { notices } from "../../mocks/obsidian";
import { waitForAsyncWork } from "../../support/async";
import { deferred, waitForAsyncWork } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";
const connectionMock = vi.hoisted(() => {
@ -908,14 +908,6 @@ function turnCompletedNotification(threadId: string, turnId: string): Extract<Se
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement {
const composer = view.containerEl.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input");
if (!composer) throw new Error("Expected composer input");

View file

@ -39,6 +39,7 @@ import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import type { TurnStartResponse } from "../../../src/generated/app-server/v2/TurnStartResponse";
import { deferred } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";
installObsidianDomShims();
@ -472,16 +473,6 @@ async function flushPromises(): Promise<void> {
await Promise.resolve();
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function runOptions(clientFactory: SelectionRewriteClientFactory): Parameters<typeof runSelectionRewrite>[0] {
return {
codexPath: "/bin/codex",

View file

@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import type * as ThreadNamingModule from "../../../src/app-server/thread-naming";
import { waitForAsyncWork } from "../../support/async";
import { deferred, waitForAsyncWork } from "../../support/async";
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
const connectionMock = vi.hoisted(() => {
@ -422,13 +422,3 @@ function turnFixture(items: Turn["items"], overrides: Partial<Turn> = {}): Turn
...overrides,
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}

View file

@ -1,3 +1,19 @@
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
export async function waitForAsyncWork(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {