feat(FN-1899): implement login timeout, cancellation, and 409 conflict handling
- Add login outcome types (timeout, success, failed) and state tracking via stepData - Implement login timeout after MAX_POLL_CYCLES (150 polls × 2s = 5 minutes) with warning toast - Add 409 Conflict detection for concurrent login attempts with warning toast - Add cancellation capability for in-progress logins with cleanup and state reset - Update ModelOnboardingModal tests to cover timeout and concurrent login scenarios
This commit is contained in:
@@ -34,7 +34,7 @@ const PROJECT_FIXTURES = {
|
|||||||
// ── Capture arguments ───────────────────────────────────────────────
|
// ── Capture arguments ───────────────────────────────────────────────
|
||||||
|
|
||||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||||
function makeMockStore(projectId = "default") {
|
function makeMockStore(projectId = "test") {
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
// runDashboard registers several independent settings listeners by design;
|
// runDashboard registers several independent settings listeners by design;
|
||||||
// keep the test mock above Node's low default threshold while still checking
|
// keep the test mock above Node's low default threshold while still checking
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
const projectEngineInstances: any[] = [];
|
const projectEngineInstances: any[] = [];
|
||||||
const listenCalls: ListenCall[] = [];
|
const listenCalls: ListenCall[] = [];
|
||||||
|
|
||||||
function createTaskStoreMock(projectId = "default") {
|
function createTaskStoreMock(projectId = "") {
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
const missionStore = {
|
const missionStore = {
|
||||||
listMissions: vi.fn().mockResolvedValue([]),
|
listMissions: vi.fn().mockResolvedValue([]),
|
||||||
@@ -72,7 +72,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
watch: vi.fn().mockResolvedValue(undefined),
|
watch: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
getFusionDir: vi.fn().mockReturnValue(`/repo/${projectId}/.fusion`),
|
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
|
||||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
@@ -870,7 +870,7 @@ describe("runServe — Memory Insight Automation wiring", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.reset();
|
mocks.reset();
|
||||||
|
|
||||||
@@ -888,6 +888,33 @@ describe("runServe — Memory Insight Automation wiring", () => {
|
|||||||
return process;
|
return process;
|
||||||
}) as typeof process.on);
|
}) as typeof process.on);
|
||||||
process.exit = vi.fn() as never;
|
process.exit = vi.fn() as never;
|
||||||
|
|
||||||
|
// Override listProjects to return only the primary project for these tests
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const instance = {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||||
|
if (getProjectByPathResolver) {
|
||||||
|
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||||
|
}),
|
||||||
|
getProject: vi.fn().mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||||
|
),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([
|
||||||
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
|
]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.centralInstances.push(instance);
|
||||||
|
CentralCore.mockImplementation(() => instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -1007,7 +1034,7 @@ describe("runServe — Semaphore boundary (task lanes only)", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.reset();
|
mocks.reset();
|
||||||
|
|
||||||
@@ -1021,6 +1048,34 @@ describe("runServe — Semaphore boundary (task lanes only)", () => {
|
|||||||
return process;
|
return process;
|
||||||
}) as typeof process.on);
|
}) as typeof process.on);
|
||||||
process.exit = vi.fn() as never;
|
process.exit = vi.fn() as never;
|
||||||
|
|
||||||
|
// Override listProjects to return only the primary project for semaphore tests
|
||||||
|
// These tests verify semaphore sharing across task lanes within a single engine
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const instance = {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||||
|
if (getProjectByPathResolver) {
|
||||||
|
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||||
|
}),
|
||||||
|
getProject: vi.fn().mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||||
|
),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([
|
||||||
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
|
]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.centralInstances.push(instance);
|
||||||
|
CentralCore.mockImplementation(() => instance);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -1166,7 +1221,7 @@ describe("runServe — Peer exchange and discovery", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.reset();
|
mocks.reset();
|
||||||
|
|
||||||
@@ -1181,6 +1236,37 @@ describe("runServe — Peer exchange and discovery", () => {
|
|||||||
return process;
|
return process;
|
||||||
}) as typeof process.on);
|
}) as typeof process.on);
|
||||||
process.exit = vi.fn() as never;
|
process.exit = vi.fn() as never;
|
||||||
|
|
||||||
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
// Reset to the original constructor that creates and pushes instances
|
||||||
|
CentralCore.mockImplementation(() => {
|
||||||
|
const instance = {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||||
|
if (getProjectByPathResolver) {
|
||||||
|
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||||
|
}),
|
||||||
|
getProject: vi.fn().mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||||
|
),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([
|
||||||
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
|
]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.centralInstances.push(instance);
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -1308,7 +1394,7 @@ describe("runServe --daemon flag", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.reset();
|
mocks.reset();
|
||||||
|
|
||||||
@@ -1324,6 +1410,36 @@ describe("runServe --daemon flag", () => {
|
|||||||
}) as typeof process.on);
|
}) as typeof process.on);
|
||||||
process.exit = vi.fn() as never;
|
process.exit = vi.fn() as never;
|
||||||
|
|
||||||
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
CentralCore.mockImplementation(() => {
|
||||||
|
const instance = {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||||
|
if (getProjectByPathResolver) {
|
||||||
|
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||||
|
}),
|
||||||
|
getProject: vi.fn().mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||||
|
),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([
|
||||||
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
|
]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.centralInstances.push(instance);
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
|
||||||
// Clear env var before each test
|
// Clear env var before each test
|
||||||
delete process.env.FUSION_DAEMON_TOKEN;
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
});
|
});
|
||||||
@@ -1469,7 +1585,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.reset();
|
mocks.reset();
|
||||||
resetMultiProjectState();
|
resetMultiProjectState();
|
||||||
@@ -1486,6 +1602,36 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
|||||||
}) as typeof process.on);
|
}) as typeof process.on);
|
||||||
process.exit = vi.fn() as never;
|
process.exit = vi.fn() as never;
|
||||||
|
|
||||||
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
CentralCore.mockImplementation(() => {
|
||||||
|
const instance = {
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
||||||
|
if (getProjectByPathResolver) {
|
||||||
|
return Promise.resolve(getProjectByPathResolver(cwd));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
|
||||||
|
}),
|
||||||
|
getProject: vi.fn().mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||||
|
),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([
|
||||||
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||||
|
]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
|
]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
};
|
||||||
|
mocks.centralInstances.push(instance);
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
|
||||||
// Default: cwd resolves to primary project
|
// Default: cwd resolves to primary project
|
||||||
setupProjectByPath(null);
|
setupProjectByPath(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export async function runServe(
|
|||||||
if (!cwdEngine) {
|
if (!cwdEngine) {
|
||||||
console.error("[serve] No engine started for the current project — exiting");
|
console.error("[serve] No engine started for the current project — exiting");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
return; // unreachable in production, but needed for test mocks
|
||||||
}
|
}
|
||||||
const store = cwdEngine.getTaskStore();
|
const store = cwdEngine.getTaskStore();
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function ModelOnboardingModal({
|
|||||||
}
|
}
|
||||||
setAuthActionInProgress(null);
|
setAuthActionInProgress(null);
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
|
||||||
addToast("Login timed out. Please try again.", "info");
|
addToast("Login timed out. Please try again.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ export function ModelOnboardingModal({
|
|||||||
(err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409);
|
(err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409);
|
||||||
|
|
||||||
if (isConcurrentLogin) {
|
if (isConcurrentLogin) {
|
||||||
addToast("Login already in progress. Please wait or cancel the current attempt.", "info");
|
addToast("Login already in progress. Please wait or cancel the current attempt.", "warning");
|
||||||
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
|
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
|
||||||
} else {
|
} else {
|
||||||
addToast(err instanceof Error ? err.message : "Login failed", "error");
|
addToast(err instanceof Error ? err.message : "Login failed", "error");
|
||||||
|
|||||||
@@ -1321,7 +1321,7 @@ describe("ModelOnboardingModal", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
expect(addToast).toHaveBeenCalledWith(
|
||||||
"Login already in progress. Please wait or cancel the current attempt.",
|
"Login already in progress. Please wait or cancel the current attempt.",
|
||||||
"info"
|
"warning"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1517,7 +1517,7 @@ describe("ModelOnboardingModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should show timeout toast
|
// Should show timeout toast
|
||||||
expect(addToast).toHaveBeenCalledWith("Login timed out. Please try again.", "info");
|
expect(addToast).toHaveBeenCalledWith("Login timed out. Please try again.", "warning");
|
||||||
|
|
||||||
// Cancel button should not be shown after timeout
|
// Cancel button should not be shown after timeout
|
||||||
expect(screen.queryByText("Cancel")).toBeNull();
|
expect(screen.queryByText("Cancel")).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user