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:
Fusion
2026-04-16 00:37:01 -07:00
committed by gsxdsm
parent 4fb8be4273
commit a9d6c4159d
5 changed files with 159 additions and 12 deletions

View File

@@ -34,7 +34,7 @@ const PROJECT_FIXTURES = {
// ── Capture arguments ───────────────────────────────────────────────
// Minimal mock store backed by EventEmitter so `store.on` works
function makeMockStore(projectId = "default") {
function makeMockStore(projectId = "test") {
const emitter = new EventEmitter();
// runDashboard registers several independent settings listeners by design;
// keep the test mock above Node's low default threshold while still checking

View File

@@ -62,7 +62,7 @@ const mocks = vi.hoisted(() => {
const projectEngineInstances: any[] = [];
const listenCalls: ListenCall[] = [];
function createTaskStoreMock(projectId = "default") {
function createTaskStoreMock(projectId = "") {
const emitter = new EventEmitter();
const missionStore = {
listMissions: vi.fn().mockResolvedValue([]),
@@ -72,7 +72,7 @@ const mocks = vi.hoisted(() => {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getFusionDir: vi.fn().mockReturnValue(`/repo/${projectId}/.fusion`),
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
@@ -870,7 +870,7 @@ describe("runServe — Memory Insight Automation wiring", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mocks.reset();
@@ -888,6 +888,33 @@ describe("runServe — Memory Insight Automation wiring", () => {
return process;
}) as typeof process.on);
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(() => {
@@ -1007,7 +1034,7 @@ describe("runServe — Semaphore boundary (task lanes only)", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mocks.reset();
@@ -1021,6 +1048,34 @@ describe("runServe — Semaphore boundary (task lanes only)", () => {
return process;
}) as typeof process.on);
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(() => {
@@ -1166,7 +1221,7 @@ describe("runServe — Peer exchange and discovery", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mocks.reset();
@@ -1181,6 +1236,37 @@ describe("runServe — Peer exchange and discovery", () => {
return process;
}) as typeof process.on);
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(() => {
@@ -1308,7 +1394,7 @@ describe("runServe --daemon flag", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mocks.reset();
@@ -1324,6 +1410,36 @@ describe("runServe --daemon flag", () => {
}) as typeof process.on);
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
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));
}
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
mocks.reset();
resetMultiProjectState();
@@ -1486,6 +1602,36 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
}) as typeof process.on);
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
setupProjectByPath(null);
});