feat(FN-2394): add Fusion reinstall flow for Pi extensions

- Add dashboard route and server handling to reinstall the Fusion Pi extension
- Introduce a client API helper and wire a reinstall action into PiExtensionsManager
- Polish action spacing and document Fusion reinstall recovery in the CLI README
- Expand route, API, and component tests including reinstall refresh stability coverage
- Add a changeset for @runfusion/fusion patch release
This commit is contained in:
Fusion
2026-04-24 05:05:11 -07:00
committed by gsxdsm
parent 0ebe20e487
commit f078a4e946
11 changed files with 244 additions and 31 deletions

View File

@@ -23,10 +23,12 @@ const existsSyncMock = vi.fn((_path: PathLike) => false);
const readFileSyncMock = vi.fn((_path?: any) => "{}");
// Route async `exec` through the `execSync` mock so the promisify bridge works.
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
// in the mock factory (which can cause occasional module-loader deadlocks).
vi.mock("node:child_process", () => {
const execSyncFn = execSyncMock;
const kPromisifyCustom = Symbol.for("nodejs.util.promisify.custom");
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : (opts ?? {});
@@ -41,10 +43,9 @@ vi.mock("node:child_process", async () => {
}
}
});
execFn[promisify.custom] = (cmd: string, opts?: any) =>
execFn[kPromisifyCustom] = (cmd: string, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
if (err) {
(err as Record<string, unknown>).stdout = stdout;

View File

@@ -67,6 +67,12 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
} as unknown as TaskStore;
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("pathsOverlap", () => {
it("returns false for empty arrays", () => {
expect(pathsOverlap([], [])).toBe(false);
@@ -204,7 +210,7 @@ describe("Scheduler", () => {
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Find and call the task:created handler
const onCalls = (store.on as any).mock.calls;
@@ -216,7 +222,7 @@ describe("Scheduler", () => {
await createdHandler(newTask);
// Wait for async schedule to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Verify schedule() was called (moveTask should be called since task can start)
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
@@ -257,7 +263,7 @@ describe("Scheduler", () => {
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Find and call the task:moved handler
const onCalls = (store.on as any).mock.calls;
@@ -269,7 +275,7 @@ describe("Scheduler", () => {
await movedHandler({ task: doneTask, from: "in-progress", to: "done" });
// Wait for async schedule to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Verify schedule() was called - FN-002 should now be able to start
expect(store.moveTask).toHaveBeenCalledWith("FN-002", "in-progress");
@@ -328,7 +334,7 @@ describe("Scheduler", () => {
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Find and call the task:moved handler
const onCalls = (store.on as any).mock.calls;
@@ -340,7 +346,7 @@ describe("Scheduler", () => {
await movedHandler({ task: todoTask, from: "in-progress", to: "todo" });
// Wait for async schedule to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Verify schedule() was called — task in todo should be scheduled
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
@@ -370,7 +376,7 @@ describe("Scheduler", () => {
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Find the task:updated handler
const onCalls = (store.on as any).mock.calls;
@@ -384,7 +390,7 @@ describe("Scheduler", () => {
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined }));
// Wait for async scheduling to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Should have triggered scheduling and moved the task to in-progress
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
@@ -422,7 +428,7 @@ describe("Scheduler", () => {
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Clear calls from initial schedule
(store.moveTask as any).mockClear();
@@ -433,7 +439,7 @@ describe("Scheduler", () => {
// Fire task:updated for a task that was never paused — should NOT trigger extra scheduling
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined }));
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// moveTask should not be called (no scheduling triggered)
expect(store.moveTask).not.toHaveBeenCalled();
@@ -448,7 +454,7 @@ describe("Scheduler", () => {
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
(store.moveTask as any).mockClear();
@@ -459,7 +465,7 @@ describe("Scheduler", () => {
await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: true }));
await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: undefined }));
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Scheduler should NOT try to schedule an in-progress task
expect(store.moveTask).not.toHaveBeenCalled();
@@ -478,7 +484,7 @@ describe("Scheduler", () => {
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Clear calls from initial start() schedule
scheduleSpy.mockClear();
@@ -1090,7 +1096,7 @@ describe("Scheduler", () => {
movedHandler({ task, from: "in-review", to: "done" });
// Wait for the void Promise.resolve chain to complete
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(onClosedPrFeedback).toHaveBeenCalledWith("FN-001", task.prInfo, mockComments);
@@ -1122,7 +1128,7 @@ describe("Scheduler", () => {
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(onClosedPrFeedback).not.toHaveBeenCalled();
@@ -1154,7 +1160,7 @@ describe("Scheduler", () => {
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
expect(prMonitor.drainComments).not.toHaveBeenCalled();
expect(onClosedPrFeedback).not.toHaveBeenCalled();
@@ -1189,7 +1195,7 @@ describe("Scheduler", () => {
// Should not throw
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
expect(prMonitor.drainComments).toHaveBeenCalledWith("FN-001");
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
@@ -1225,7 +1231,7 @@ describe("Scheduler", () => {
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// drainComments should be called before stopMonitoring
expect(callOrder).toEqual(["drainComments", "stopMonitoring"]);
@@ -1256,11 +1262,11 @@ describe("Scheduler", () => {
// First move — comments were already drained, buffer is empty
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// Second move — still empty
movedHandler({ task, from: "in-review", to: "done" });
await new Promise((r) => setTimeout(r, 10));
await flushAsyncWork();
// onClosedPrFeedback should never be called since buffer is empty
expect(onClosedPrFeedback).not.toHaveBeenCalled();