mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
|
|
import {
|
|
type ChatConnectionActionsHost,
|
|
type ChatConnectionAdapter,
|
|
type ChatConnectionDiagnosticsActions,
|
|
type ChatConnectionMetadataActions,
|
|
createChatConnectionActions,
|
|
} from "../../../../../src/features/chat/application/connection/connection-actions";
|
|
import { createChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
|
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
|
import { deferred } from "../../../../support/async";
|
|
|
|
function createActionsHarness({ connected = false } = {}) {
|
|
const stateStore = createChatStateStore(createChatState());
|
|
let isConnected = connected;
|
|
const connect = vi.fn().mockImplementation(async () => {
|
|
isConnected = true;
|
|
return { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" };
|
|
});
|
|
const connection: ChatConnectionAdapter = {
|
|
connect,
|
|
isConnected: () => isConnected,
|
|
};
|
|
const refreshAppServerMetadata = vi.fn().mockResolvedValue(null);
|
|
const refreshServerDiagnostics = vi.fn().mockResolvedValue(undefined);
|
|
const metadata = {
|
|
refreshAppServerMetadata,
|
|
} satisfies ChatConnectionMetadataActions;
|
|
const diagnostics = {
|
|
refreshServerDiagnostics,
|
|
} satisfies ChatConnectionDiagnosticsActions;
|
|
const host: ChatConnectionActionsHost = {
|
|
stateStore,
|
|
connection,
|
|
metadata,
|
|
diagnostics,
|
|
invalidateThreadWork: vi.fn(),
|
|
refreshSharedThreads: vi.fn().mockResolvedValue(undefined),
|
|
scheduleDeferredDiagnostics: vi.fn(),
|
|
clearDeferredDiagnostics: vi.fn(),
|
|
refreshTabHeader: vi.fn(),
|
|
resetThreadTurnPresence: vi.fn(),
|
|
setStatus: vi.fn(),
|
|
addSystemMessage: vi.fn(),
|
|
configuredCommand: () => "codex",
|
|
refreshLiveState: vi.fn(),
|
|
isStaleConnectionError: () => false,
|
|
isStaleResourceContextError: () => false,
|
|
notifyConnectionFailed: vi.fn(),
|
|
};
|
|
return {
|
|
connect,
|
|
actions: createChatConnectionActions(host),
|
|
host,
|
|
refreshAppServerMetadata,
|
|
refreshServerDiagnostics,
|
|
setConnected: (value: boolean) => {
|
|
isConnected = value;
|
|
},
|
|
stateStore,
|
|
};
|
|
}
|
|
|
|
describe("ChatConnectionActions", () => {
|
|
it("coalesces concurrent connection attempts inside the actions boundary", async () => {
|
|
const { actions, connect, setConnected } = createActionsHarness();
|
|
const pending = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
|
|
connect.mockImplementationOnce(() => pending.promise);
|
|
|
|
const first = actions.ensureConnected();
|
|
const second = actions.ensureConnected();
|
|
|
|
expect(connect).toHaveBeenCalledOnce();
|
|
setConnected(true);
|
|
pending.resolve({ codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
|
|
await Promise.all([first, second]);
|
|
});
|
|
|
|
it("starts fresh work after invalidation and ignores the obsolete result", async () => {
|
|
const { actions, connect, setConnected, stateStore } = createActionsHarness();
|
|
const obsolete = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
|
|
const current = deferred<Awaited<ReturnType<ChatConnectionAdapter["connect"]>>>();
|
|
connect.mockImplementationOnce(() => obsolete.promise).mockImplementationOnce(() => current.promise);
|
|
|
|
const first = actions.ensureConnected();
|
|
actions.invalidate();
|
|
const second = actions.ensureConnected();
|
|
setConnected(true);
|
|
current.resolve({ codexHome: "/current", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
|
|
await second;
|
|
obsolete.resolve({ codexHome: "/obsolete", platformFamily: "unix", platformOs: "macos", userAgent: "test" });
|
|
await first;
|
|
|
|
expect(connect).toHaveBeenCalledTimes(2);
|
|
expect(stateStore.getState().connection.initializeResponse?.codexHome).toBe("/current");
|
|
});
|
|
|
|
it("connects once and publishes startup metadata", async () => {
|
|
const { connect, actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness();
|
|
|
|
await actions.ensureConnected();
|
|
|
|
expect(connect).toHaveBeenCalledOnce();
|
|
expect(stateStore.getState().connection.initializeResponse).toEqual({
|
|
codexHome: "/codex",
|
|
platformFamily: "unix",
|
|
platformOs: "macos",
|
|
userAgent: "test",
|
|
});
|
|
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
|
|
expect(host.refreshSharedThreads).toHaveBeenCalledOnce();
|
|
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
|
|
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
|
|
});
|
|
|
|
it("keeps the initialized connection usable when metadata hydration fails", async () => {
|
|
const { actions, host, refreshAppServerMetadata, stateStore } = createActionsHarness();
|
|
refreshAppServerMetadata.mockRejectedValueOnce(new Error("config unavailable"));
|
|
|
|
await actions.ensureConnected();
|
|
|
|
expect(stateStore.getState().connection.initializeResponse).toMatchObject({ codexHome: "/codex" });
|
|
expect(host.refreshSharedThreads).toHaveBeenCalledOnce();
|
|
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
|
|
expect(host.setStatus).not.toHaveBeenCalledWith("Connection failed.", expect.anything());
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Could not refresh Codex metadata: config unavailable");
|
|
expect(host.notifyConnectionFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the initialized connection usable when thread hydration fails", async () => {
|
|
const { actions, host } = createActionsHarness();
|
|
vi.mocked(host.refreshSharedThreads).mockRejectedValueOnce(new Error("threads unavailable"));
|
|
|
|
await actions.ensureConnected();
|
|
|
|
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
|
|
expect(host.setStatus).not.toHaveBeenCalledWith("Connection failed.", expect.anything());
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith("Could not refresh Codex threads: threads unavailable");
|
|
expect(host.notifyConnectionFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refreshes metadata before server diagnostics", async () => {
|
|
const { actions, host, refreshAppServerMetadata, refreshServerDiagnostics } = createActionsHarness({ connected: true });
|
|
|
|
await actions.refreshDiagnostics();
|
|
|
|
expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2);
|
|
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
|
|
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
|
|
});
|
|
|
|
it("refreshes active threads without refreshing metadata", async () => {
|
|
const { actions, host, refreshAppServerMetadata } = createActionsHarness({ connected: true });
|
|
|
|
await actions.refreshActiveThreads();
|
|
|
|
expect(host.refreshSharedThreads).toHaveBeenCalledOnce();
|
|
expect(refreshAppServerMetadata).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores stale resource context failures while refreshing active threads", async () => {
|
|
const { actions, host } = createActionsHarness({ connected: true });
|
|
const error = new Error("stale");
|
|
vi.mocked(host.refreshSharedThreads).mockRejectedValueOnce(error);
|
|
host.isStaleResourceContextError = vi.fn((candidate) => candidate === error);
|
|
|
|
await actions.refreshActiveThreads();
|
|
|
|
expect(host.isStaleResourceContextError).toHaveBeenCalledWith(error);
|
|
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears disconnected connection state on server exit while keeping last startup metadata", () => {
|
|
const { actions, host, stateStore } = createActionsHarness({ connected: true });
|
|
const initializeResponse = { codexHome: "/codex", platformFamily: "unix", platformOs: "macos", userAgent: "test" } as const;
|
|
const runtimeConfig = { ...emptyRuntimeConfigSnapshot(), model: "gpt-5.1" };
|
|
stateStore.dispatch({ type: "connection/initialized", initializeResponse });
|
|
stateStore.dispatch({
|
|
type: "thread-list/applied",
|
|
threads: [{ id: "thread-1", title: "Thread 1" } as never],
|
|
});
|
|
stateStore.dispatch({
|
|
type: "connection/metadata-applied",
|
|
availableModels: [{ id: "model-1" } as never],
|
|
availableSkills: [{ name: "skill-1" } as never],
|
|
runtimeConfig,
|
|
});
|
|
|
|
actions.handleExit();
|
|
|
|
expect(host.invalidateThreadWork).toHaveBeenCalledOnce();
|
|
expect(host.setStatus).toHaveBeenCalledWith("Codex app-server stopped.", {
|
|
kind: "disconnected",
|
|
message: "Codex app-server stopped.",
|
|
});
|
|
expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false);
|
|
expect(host.refreshLiveState).toHaveBeenCalledOnce();
|
|
expect(stateStore.getState()).toMatchObject({
|
|
threadList: {
|
|
listedThreads: [],
|
|
},
|
|
connection: {
|
|
availableModels: [],
|
|
availableSkills: [],
|
|
runtimeConfig,
|
|
initializeResponse,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("explains missing configured command failures", async () => {
|
|
const { actions, connect, host } = createActionsHarness();
|
|
const error = Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT", syscall: "spawn" });
|
|
connect.mockRejectedValueOnce(error);
|
|
|
|
await actions.ensureConnected();
|
|
|
|
expect(host.setStatus).toHaveBeenCalledWith("Connection failed.", {
|
|
kind: "failed",
|
|
message:
|
|
"Could not start Codex app-server because the configured command was not found: codex. Check the Codex command path in settings. (spawn codex ENOENT)",
|
|
});
|
|
expect(host.addSystemMessage).toHaveBeenCalledWith(
|
|
"Could not start Codex app-server because the configured command was not found: codex. Check the Codex command path in settings. (spawn codex ENOENT)",
|
|
);
|
|
expect(host.notifyConnectionFailed).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("ignores stale connection failures during startup", async () => {
|
|
const { actions, connect, host } = createActionsHarness();
|
|
const error = new Error("stale connection");
|
|
connect.mockRejectedValueOnce(error);
|
|
host.isStaleConnectionError = vi.fn((candidate) => candidate === error);
|
|
|
|
await actions.ensureConnected();
|
|
|
|
expect(host.isStaleConnectionError).toHaveBeenCalledWith(error);
|
|
expect(host.setStatus).toHaveBeenCalledWith("Starting Codex app-server...", { kind: "connecting" });
|
|
expect(host.setStatus).not.toHaveBeenCalledWith("Connection failed.", expect.anything());
|
|
expect(host.addSystemMessage).not.toHaveBeenCalled();
|
|
expect(host.notifyConnectionFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|