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 ───────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user