From ea00f9a7e9bb3eee2a8c8d5e7069802579a26f7c Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 2 Jun 2026 11:43:06 +0900 Subject: [PATCH] Share lifecycle and async test helpers --- src/features/chat/view-lifecycle.ts | 83 +++++++------------ src/features/threads-view/view-lifecycle.ts | 55 +++++------- src/shared/lifecycle/connection-work.ts | 20 +++++ src/shared/lifecycle/deferred-task.ts | 24 ++++++ .../thread/restored-thread-controller.test.ts | 9 +- tests/features/chat/thread-history.test.ts | 11 +-- tests/features/chat/thread-rename.test.ts | 11 +-- tests/features/chat/view-connection.test.ts | 10 +-- .../selection-rewrite.test.ts | 11 +-- tests/features/threads-view/view.test.ts | 12 +-- tests/support/async.ts | 16 ++++ 11 files changed, 118 insertions(+), 144 deletions(-) create mode 100644 src/shared/lifecycle/connection-work.ts create mode 100644 src/shared/lifecycle/deferred-task.ts diff --git a/src/features/chat/view-lifecycle.ts b/src/features/chat/view-lifecycle.ts index 589dda87..d24050c6 100644 --- a/src/features/chat/view-lifecycle.ts +++ b/src/features/chat/view-lifecycle.ts @@ -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; export type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" }; -export type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; -export type ActiveChatConnection = Extract; -export type ChatConnectionLifecycleEvent = - | { type: "started"; connection: ActiveChatConnection } - | { type: "finished"; connection: ActiveChatConnection; promise: Promise } - | { 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 } | { type: "cleared" }; -type TimerWindow = Pick; - 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 { diff --git a/src/features/threads-view/view-lifecycle.ts b/src/features/threads-view/view-lifecycle.ts index c0f0595e..c22ac8ce 100644 --- a/src/features/threads-view/view-lifecycle.ts +++ b/src/features/threads-view/view-lifecycle.ts @@ -1,4 +1,10 @@ -type TimerWindow = Pick; +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; @@ -7,33 +13,25 @@ export type ThreadsViewRefreshLifecycleEvent = | { type: "finished"; refresh: ActiveThreadsViewRefresh } | { type: "invalidated" }; -export type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; -export type ActiveThreadsViewConnection = Extract; -export type ThreadsViewConnectionLifecycleEvent = - | { type: "started"; connection: ActiveThreadsViewConnection } - | { type: "finished"; connection: ActiveThreadsViewConnection; promise: Promise } - | { type: "invalidated" }; +export type ThreadsViewConnectionLifecycleState = ConnectionWorkLifecycleState; +export type ActiveThreadsViewConnection = ActiveConnectionWork; +export type ThreadsViewConnectionLifecycleEvent = ConnectionWorkLifecycleEvent; export class ThreadsViewDeferredTasks { - private renderTimer: ReturnType | null = null; - private refreshTimer: ReturnType | 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); } diff --git a/src/shared/lifecycle/connection-work.ts b/src/shared/lifecycle/connection-work.ts new file mode 100644 index 00000000..d5d42ec3 --- /dev/null +++ b/src/shared/lifecycle/connection-work.ts @@ -0,0 +1,20 @@ +export type ConnectionWorkLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; +export type ActiveConnectionWork = Extract; +export type ConnectionWorkLifecycleEvent = + | { type: "started"; connection: ActiveConnectionWork } + | { type: "finished"; connection: ActiveConnectionWork; promise: Promise } + | { 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" }; + } +} diff --git a/src/shared/lifecycle/deferred-task.ts b/src/shared/lifecycle/deferred-task.ts new file mode 100644 index 00000000..49df4ca1 --- /dev/null +++ b/src/shared/lifecycle/deferred-task.ts @@ -0,0 +1,24 @@ +export type DeferredTaskWindow = Pick; + +export class DeferredTask { + private timer: ReturnType | 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; + } +} diff --git a/tests/features/chat/controllers/thread/restored-thread-controller.test.ts b/tests/features/chat/controllers/thread/restored-thread-controller.test.ts index 522618a3..64c3a51f 100644 --- a/tests/features/chat/controllers/thread/restored-thread-controller.test.ts +++ b/tests/features/chat/controllers/thread/restored-thread-controller.test.ts @@ -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 = {}): ...overrides, }; } - -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - return { promise, resolve }; -} diff --git a/tests/features/chat/thread-history.test.ts b/tests/features/chat/thread-history.test.ts index 76007746..643c12a3 100644 --- a/tests/features/chat/thread-history.test.ts +++ b/tests/features/chat/thread-history.test.ts @@ -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(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} diff --git a/tests/features/chat/thread-rename.test.ts b/tests/features/chat/thread-rename.test.ts index ced5485f..ce2364e2 100644 --- a/tests/features/chat/thread-rename.test.ts +++ b/tests/features/chat/thread-rename.test.ts @@ -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(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} - async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index dbd96c33..cc1b6508 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -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(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void; - const promise = new Promise((innerResolve) => { - resolve = innerResolve; - }); - return { promise, resolve }; -} - function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement { const composer = view.containerEl.querySelector(".codex-panel__composer-input"); if (!composer) throw new Error("Expected composer input"); diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 91499ff4..6c93ffb7 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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 { await Promise.resolve(); } -function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} - function runOptions(clientFactory: SelectionRewriteClientFactory): Parameters[0] { return { codexPath: "/bin/codex", diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index f1aca087..18e2c45f 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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 ...overrides, }; } - -function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - return { promise, resolve, reject }; -} diff --git a/tests/support/async.ts b/tests/support/async.ts index 22ffa2fe..743a8ee2 100644 --- a/tests/support/async.ts +++ b/tests/support/async.ts @@ -1,3 +1,19 @@ +export interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +export function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + export async function waitForAsyncWork(assertion: () => void): Promise { let lastError: unknown; for (let attempt = 0; attempt < 20; attempt += 1) {