fix: harden plugin failure recovery (#2169)

This commit is contained in:
Carlos Florêncio
2026-08-02 22:25:27 +01:00
committed by GitHub
parent d6602c9fdf
commit 87239d1b29
35 changed files with 1699 additions and 160 deletions
+103
View File
@@ -88,6 +88,26 @@ async function uninstallViaApi(apiClient: ApiClient) {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
}
async function installedPluginPath(apiClient: ApiClient): Promise<string> {
const response = await apiClient.rawRequest("GET", "/api/plugins");
if (!response.ok) throw new Error(`GET /api/plugins failed: ${response.status}`);
const body = (await response.json()) as {
plugins?: Array<{ id: string; install_path: string }>;
};
const record = body.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
if (!record) throw new Error(`plugin ${PLUGIN_ID} was not returned by GET /api/plugins`);
return record.install_path;
}
function pluginExecutablePath(installPath: string): string {
const serverDir = path.join(installPath, "server");
const executable = fs
.readdirSync(serverDir, { withFileTypes: true })
.find((entry) => entry.isFile())?.name;
if (!executable) throw new Error(`no plugin executable found under ${serverDir}`);
return path.join(serverDir, executable);
}
/**
* Stages a filesystem sideload: extracts the same fixture package the
* upload tests use directly into `<pluginsDir>/<id>/<version>/`, with no
@@ -318,6 +338,89 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect(globalToggle).toHaveAttribute("aria-checked", "false");
});
test("shows boot failure diagnostics and retries an errored plugin", async ({
testPage,
apiClient,
backend,
}) => {
test.setTimeout(120_000);
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await expect(pluginRow).toBeVisible({ timeout: 15_000 });
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible();
const installPath = await installedPluginPath(apiClient);
const executablePath = pluginExecutablePath(installPath);
const unavailablePath = `${executablePath}.unavailable`;
fs.renameSync(executablePath, unavailablePath);
try {
await backend.restart();
await expect
.poll(
async () => {
const response = await apiClient.rawRequest("GET", `/api/plugins/${PLUGIN_ID}`);
if (!response.ok) return "missing";
return ((await response.json()) as { status?: string }).status ?? "missing";
},
{ timeout: 20_000, intervals: [250, 500, 1000] },
)
.toBe("error");
await testPage.goto("/settings/plugins");
await expect(pluginRow.getByText("Error", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(pluginRow.getByTestId(`plugin-error-${PLUGIN_ID}`)).toBeVisible();
await expect(pluginRow.getByRole("button", { name: "Enable" })).toBeVisible();
await pluginRow.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click();
const detail = testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`);
await expect(detail.getByTestId(`plugin-error-${PLUGIN_ID}`)).toBeVisible();
await expect(detail.getByRole("button", { name: "Enable" })).toBeVisible();
const firstDiagnostic = await detail
.getByTestId(`plugin-error-${PLUGIN_ID}`)
.locator("span")
.textContent();
if (!firstDiagnostic) throw new Error("initial plugin diagnostic was empty");
// The first retry still sees the missing executable. The hook must
// refetch the backend record even though the action remains failed.
await detail.getByRole("button", { name: "Enable" }).click();
await expect(detail.getByText("Error", { exact: true })).toBeVisible({ timeout: 15_000 });
// Change the failure mode without restoring the real binary. Linux
// reports an executable-format error, which must replace the original
// missing-file diagnostic in the detail view.
fs.writeFileSync(executablePath, "not a go-plugin executable\n");
if (process.platform !== "win32") fs.chmodSync(executablePath, 0o755);
await detail.getByRole("button", { name: "Enable" }).click();
await expect(detail.getByText("Error", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () =>
(await detail.getByTestId(`plugin-error-${PLUGIN_ID}`).locator("span").textContent()) ??
"",
{ timeout: 15_000, intervals: [250, 500, 1000] },
)
.not.toBe(firstDiagnostic);
fs.rmSync(executablePath, { force: true });
fs.renameSync(unavailablePath, executablePath);
await detail.getByRole("button", { name: "Enable" }).click();
await expect(detail.getByText("Active", { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(detail.getByTestId(`plugin-error-${PLUGIN_ID}`)).toHaveCount(0);
} finally {
if (fs.existsSync(unavailablePath) && fs.existsSync(executablePath)) {
fs.rmSync(executablePath, { force: true });
}
if (fs.existsSync(unavailablePath) && !fs.existsSync(executablePath)) {
fs.renameSync(unavailablePath, executablePath);
}
}
});
test("keybinding declared in the manifest opens a host.openModal demo modal", async ({
testPage,
}) => {