449 lines
19 KiB
TypeScript
449 lines
19 KiB
TypeScript
import { createServer } from "node:http";
|
|
|
|
import { chromium, expect } from "@playwright/test";
|
|
import type { Browser } from "@playwright/test";
|
|
import { stringify as stringifySuperJson } from "superjson";
|
|
import type { StartedTestContainer } from "testcontainers";
|
|
import { describe, test } from "vitest";
|
|
|
|
import { eq } from "@homarr/db";
|
|
import {
|
|
customWidgetDefinitions,
|
|
customWidgetSecrets,
|
|
legacyCustomWidgetDefinitions,
|
|
legacyCustomWidgetSecrets,
|
|
} from "@homarr/db/schema";
|
|
import type { HomarrCustomWidgetV2Input } from "@homarr/custom-widgets/core";
|
|
|
|
import { createHomarrContainer } from "./shared/create-homarr-container";
|
|
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
|
import type { MockApiServer } from "./shared/mock-api-server";
|
|
import { startMockApiServerAsync } from "./shared/mock-api-server";
|
|
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
|
|
|
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
|
const initialTemplate =
|
|
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
|
|
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
|
|
const workshopWidgetName = "Workshop E2E widget";
|
|
const legacyWidgetName = "Legacy E2E Widget";
|
|
const legacyWidgetId = "legacy-e2e-widget";
|
|
|
|
interface WorkshopMockServer {
|
|
url: string;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
const startWorkshopMockServerAsync = async (widget: HomarrCustomWidgetV2Input): Promise<WorkshopMockServer> => {
|
|
const submissionId = "e2eworkshop0001";
|
|
const timestamp = "2026-07-28T10:00:00.000Z";
|
|
const listing = {
|
|
id: submissionId,
|
|
collectionId: "workshop-listings",
|
|
collectionName: "workshop_listings",
|
|
type: "customWidget",
|
|
title: workshopWidgetName,
|
|
description: "Installed from the Workshop and rendered on a Homarr board.",
|
|
widgetSchema: "homarr-custom-widget-v2",
|
|
screenshots: [],
|
|
author: "e2e-author",
|
|
authorName: "E2E Workshop author",
|
|
authorAvatar: "",
|
|
authorAvatarUrl: "",
|
|
authorGithubUsername: "",
|
|
authorGithubProfileUrl: "",
|
|
score: 1,
|
|
upvotes: 1,
|
|
downvotes: 0,
|
|
commentCount: 0,
|
|
reportCount: 0,
|
|
revision: 1,
|
|
changelog: "Initial E2E submission",
|
|
outdated: false,
|
|
created: timestamp,
|
|
updated: timestamp,
|
|
};
|
|
const submission = {
|
|
id: submissionId,
|
|
collectionId: "submissions",
|
|
collectionName: "submissions",
|
|
type: listing.type,
|
|
title: listing.title,
|
|
description: listing.description,
|
|
widgetSchema: listing.widgetSchema,
|
|
content: JSON.stringify(widget),
|
|
revision: listing.revision,
|
|
changelog: listing.changelog,
|
|
outdated: listing.outdated,
|
|
screenshots: listing.screenshots,
|
|
author: listing.author,
|
|
created: timestamp,
|
|
updated: timestamp,
|
|
};
|
|
|
|
const server = createServer((request, response) => {
|
|
response.setHeader("Access-Control-Allow-Origin", "*");
|
|
response.setHeader("Access-Control-Allow-Headers", "*");
|
|
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
response.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
response.setHeader("Content-Type", "application/json");
|
|
|
|
if (request.method === "OPTIONS") {
|
|
response.writeHead(204);
|
|
response.end();
|
|
return;
|
|
}
|
|
|
|
const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
|
if (request.method === "GET" && pathname === "/api/collections/workshop_listings/records") {
|
|
response.end(
|
|
JSON.stringify({
|
|
page: 1,
|
|
perPage: 24,
|
|
totalItems: 1,
|
|
totalPages: 1,
|
|
items: [listing],
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (request.method === "GET" && pathname === `/api/collections/workshop_listings/records/${submissionId}`) {
|
|
response.end(JSON.stringify(listing));
|
|
return;
|
|
}
|
|
if (request.method === "GET" && pathname === `/api/collections/submissions/records/${submissionId}`) {
|
|
response.end(JSON.stringify(submission));
|
|
return;
|
|
}
|
|
|
|
response.writeHead(404);
|
|
response.end(JSON.stringify({ code: 404, message: "Not found.", data: {} }));
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("Workshop mock server did not bind to a TCP port");
|
|
|
|
return {
|
|
url: `http://127.0.0.1:${address.port}`,
|
|
close: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
server.closeAllConnections();
|
|
}),
|
|
};
|
|
};
|
|
|
|
const loginAsAdmin = async (browser: Browser, baseUrl: string, clipboard = false) => {
|
|
const context = await browser.newContext({
|
|
permissions: clipboard ? ["clipboard-read", "clipboard-write"] : [],
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto(`${baseUrl}/auth/login`);
|
|
await page.waitForLoadState("networkidle");
|
|
await page.getByLabel("Username").fill(adminCredentials.username);
|
|
await page.locator("#password").fill(adminCredentials.password);
|
|
await page.locator("button[type='submit']").click();
|
|
await page.waitForURL(baseUrl, { timeout: 15_000 });
|
|
return page;
|
|
};
|
|
|
|
const addCustomWidgetToBoard = async (
|
|
page: Awaited<ReturnType<typeof loginAsAdmin>>,
|
|
baseUrl: string,
|
|
name: string,
|
|
) => {
|
|
await page.goto(baseUrl);
|
|
await page.getByRole("button", { name: "Edit", exact: true }).click();
|
|
await page.getByRole("button", { name: "New item", exact: true }).click();
|
|
await page.getByRole("menuitem", { name: "New item", exact: true }).click();
|
|
const picker = page.getByRole("dialog").last();
|
|
const customWidgetCard = picker.getByText(name, { exact: true }).locator("xpath=../../..");
|
|
await customWidgetCard.hover();
|
|
await customWidgetCard.getByRole("button", { name: "Add to board" }).click();
|
|
const addDialog = page.getByRole("dialog").last();
|
|
await expect(addDialog.getByText(name, { exact: true })).toBeVisible();
|
|
await addDialog.getByRole("button", { name: "Save changes", exact: true }).click();
|
|
await page.getByRole("button", { name: "Save", exact: true }).click();
|
|
};
|
|
|
|
describe("Custom JSX v2 workbench", () => {
|
|
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
|
|
let mockApi: MockApiServer | undefined;
|
|
let homarrContainer: StartedTestContainer | undefined;
|
|
let browser: Browser | undefined;
|
|
|
|
try {
|
|
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
|
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
|
await seedAdminUserAsync(db, adminCredentials);
|
|
homarrContainer = await createHomarrContainer({
|
|
environment: { AUTH_PROVIDERS: "credentials" },
|
|
mounts: { "/appdata": localMountPath },
|
|
}).start();
|
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
|
browser = await chromium.launch();
|
|
const page = await loginAsAdmin(browser, baseUrl);
|
|
|
|
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
|
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
|
|
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
|
|
await page.getByRole("combobox", { name: "Network access" }).click();
|
|
await page.getByRole("option", { name: "private" }).click();
|
|
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
|
|
await page.getByRole("button", { name: "Add option" }).click();
|
|
const option = page.getByRole("group", { name: "Option 1" });
|
|
await option.getByRole("textbox", { name: "Option name" }).fill("label");
|
|
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
|
|
const request = page.getByRole("group", { name: "status" });
|
|
await request.getByRole("button", { name: "Query, body and behavior" }).click();
|
|
await request.getByRole("button", { name: "Add query value" }).click();
|
|
await request.getByRole("textbox", { name: "Query key" }).fill("label");
|
|
await request.getByRole("combobox", { name: "Value source" }).click();
|
|
await page.getByRole("option", { name: "Widget option" }).click();
|
|
await request.getByRole("combobox", { name: "Option" }).first().click();
|
|
await page.getByRole("option", { name: "label" }).click();
|
|
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
|
|
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
|
|
await page.locator("#jsx-editor").fill(initialTemplate);
|
|
|
|
await page.getByRole("button", { name: "Test and preview" }).last().click();
|
|
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
await page.getByRole("button", { name: "Create", exact: true }).last().click();
|
|
await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 });
|
|
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
|
|
|
|
await page.locator("#jsx-editor").fill(updatedTemplate);
|
|
await page.getByRole("button", { name: "Save", exact: true }).last().click();
|
|
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
|
|
|
await addCustomWidgetToBoard(page, baseUrl, "E2E Custom JSX v2");
|
|
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
|
|
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
|
|
} finally {
|
|
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
|
|
}
|
|
}, 180_000);
|
|
|
|
test("installs a Workshop widget, places it on a board, and renders its query", async () => {
|
|
let mockApi: MockApiServer | undefined;
|
|
let workshopMock: WorkshopMockServer | undefined;
|
|
let homarrContainer: StartedTestContainer | undefined;
|
|
let browser: Browser | undefined;
|
|
|
|
try {
|
|
mockApi = await startMockApiServerAsync({ title: "Workshop query result", status: "online", value: 73 });
|
|
const workshopWidget = {
|
|
$schema: "homarr-custom-widget-v2",
|
|
name: workshopWidgetName,
|
|
description: "A Workshop installation exercised by the integrated E2E suite.",
|
|
sources: {
|
|
default: {
|
|
name: "E2E API",
|
|
baseUrl: mockApi.url,
|
|
networkScope: "private",
|
|
auth: "none",
|
|
},
|
|
},
|
|
requests: { status: { path: "/status" } },
|
|
options: {},
|
|
template: updatedTemplate,
|
|
} satisfies HomarrCustomWidgetV2Input;
|
|
workshopMock = await startWorkshopMockServerAsync(workshopWidget);
|
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
|
await seedAdminUserAsync(db, adminCredentials);
|
|
homarrContainer = await createHomarrContainer({
|
|
environment: {
|
|
AUTH_PROVIDERS: "credentials",
|
|
WORKSHOP_API_URL: workshopMock.url,
|
|
},
|
|
mounts: { "/appdata": localMountPath },
|
|
}).start();
|
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
|
browser = await chromium.launch();
|
|
const page = await loginAsAdmin(browser, baseUrl);
|
|
|
|
await page.goto(`${baseUrl}/manage/workshop`);
|
|
await expect(page.getByText(workshopWidgetName, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
await page.getByRole("button", { name: "Inspect widget" }).click();
|
|
const workshopDialog = page.getByRole("dialog", { name: workshopWidgetName });
|
|
await expect(
|
|
workshopDialog.getByText("Installed from the Workshop and rendered on a Homarr board."),
|
|
).toBeVisible();
|
|
await workshopDialog.getByRole("button", { name: "Install widget" }).click();
|
|
|
|
const importDialog = page.getByRole("dialog", { name: "Review custom widget import" });
|
|
await importDialog
|
|
.getByRole("checkbox", {
|
|
name: "I confirm that this server URL is correct for my Homarr installation",
|
|
})
|
|
.check();
|
|
await importDialog.getByRole("button", { name: "Import widget" }).click();
|
|
await expect(page.getByText("Widget imported successfully.")).toBeVisible({ timeout: 15_000 });
|
|
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
|
|
|
|
await addCustomWidgetToBoard(page, baseUrl, workshopWidgetName);
|
|
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
|
|
await expect(page.getByText("Workshop query result")).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByText("73")).toBeVisible({ timeout: 15_000 });
|
|
} finally {
|
|
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), workshopMock?.close(), mockApi?.close()]);
|
|
}
|
|
}, 180_000);
|
|
|
|
test("migrates a redacted preserved v1 widget and resurfaces it when the v2 replacement is deleted", async () => {
|
|
let homarrContainer: StartedTestContainer | undefined;
|
|
let browser: Browser | undefined;
|
|
|
|
const encryptedValue = "preserved.ciphertext" as `${string}.${string}`;
|
|
const migratedWidget = {
|
|
$schema: "homarr-custom-widget-v2",
|
|
name: legacyWidgetName,
|
|
description: "Validated v2 replacement for the preserved legacy widget.",
|
|
sources: {
|
|
default: {
|
|
name: "Legacy API",
|
|
baseUrl: "https://legacy-api.homarr.dev/api",
|
|
networkScope: "public",
|
|
auth: { type: "apiKeyHeader", name: "X-E2E-Key" },
|
|
},
|
|
},
|
|
requests: { status: { path: "/status" } },
|
|
options: {},
|
|
template: "<Stack><Text>{data.status?.value}</Text></Stack>",
|
|
} satisfies HomarrCustomWidgetV2Input;
|
|
|
|
try {
|
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
|
const { userId } = await seedAdminUserAsync(db, adminCredentials);
|
|
await db.insert(legacyCustomWidgetDefinitions).values({
|
|
id: legacyWidgetId,
|
|
name: legacyWidgetName,
|
|
description: "Preserved v1 fixture",
|
|
url: "https://legacy-user:super-secret@legacy-api.homarr.dev/api/status?token=token-value#fragment",
|
|
authType: "apiKeyHeader",
|
|
headerName: "X-E2E-Key",
|
|
method: "POST",
|
|
requestBody: JSON.stringify({ password: "nested-secret", filters: ["private-filter"] }),
|
|
displayType: "singleValue",
|
|
displayConfig: stringifySuperJson({ type: "singleValue", jsonPath: "$.value" }),
|
|
enabled: true,
|
|
creatorId: userId,
|
|
});
|
|
await db.insert(legacyCustomWidgetSecrets).values({
|
|
definitionId: legacyWidgetId,
|
|
kind: "apiKey",
|
|
encryptedValue,
|
|
updatedAt: new Date(0),
|
|
});
|
|
|
|
homarrContainer = await createHomarrContainer({
|
|
environment: { AUTH_PROVIDERS: "credentials" },
|
|
mounts: { "/appdata": localMountPath },
|
|
}).start();
|
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
|
browser = await chromium.launch();
|
|
const page = await loginAsAdmin(browser, baseUrl, true);
|
|
|
|
await page.goto(`${baseUrl}/manage/custom-widgets`);
|
|
const legacyCard = page
|
|
.getByText(legacyWidgetName, { exact: true })
|
|
.locator("xpath=ancestor::*[contains(@class, 'mantine-Card-root')]");
|
|
await expect(legacyCard.getByText("Migration required", { exact: true })).toBeVisible();
|
|
await legacyCard.getByRole("button", { name: "Actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Copy LLM migration prompt" }).click();
|
|
await expect(page.getByText(/A redacted migration prompt was copied/u)).toBeVisible({ timeout: 15_000 });
|
|
const prompt = await page.evaluate(() => navigator.clipboard.readText());
|
|
expect(prompt).toContain("[REDACTED]");
|
|
expect(prompt).not.toContain("legacy-user");
|
|
expect(prompt).not.toContain("super-secret");
|
|
expect(prompt).not.toContain("token-value");
|
|
expect(prompt).not.toContain("nested-secret");
|
|
expect(prompt).not.toContain("private-filter");
|
|
|
|
await legacyCard.getByRole("button", { name: "Actions" }).click();
|
|
await page.getByLabel("Import migrated v2 JSON").setInputFiles({
|
|
name: "legacy-widget-v2.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(migratedWidget)),
|
|
});
|
|
const migrationDialog = page.getByRole("dialog", { name: "Review migrated v2 replacement" });
|
|
await migrationDialog.getByRole("button", { name: "Confirm v2 replacement" }).click();
|
|
await expect(
|
|
page.getByText("Legacy widget migrated successfully. Existing board placements now use the v2 definition."),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
expect(
|
|
await db.query.legacyCustomWidgetDefinitions.findFirst({
|
|
where: eq(legacyCustomWidgetDefinitions.id, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({ id: legacyWidgetId, name: legacyWidgetName });
|
|
expect(
|
|
await db.query.legacyCustomWidgetSecrets.findFirst({
|
|
where: eq(legacyCustomWidgetSecrets.definitionId, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({ definitionId: legacyWidgetId, encryptedValue });
|
|
expect(
|
|
await db.query.customWidgetDefinitions.findFirst({
|
|
where: eq(customWidgetDefinitions.id, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({ id: legacyWidgetId, name: legacyWidgetName });
|
|
expect(
|
|
await db.query.customWidgetSecrets.findFirst({
|
|
where: eq(customWidgetSecrets.definitionId, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({
|
|
definitionId: legacyWidgetId,
|
|
sourceId: "default",
|
|
kind: "apiKey",
|
|
encryptedValue,
|
|
});
|
|
|
|
await page.reload();
|
|
const migratedCard = page
|
|
.getByText(legacyWidgetName, { exact: true })
|
|
.locator("xpath=ancestor::*[contains(@class, 'mantine-Card-root')]");
|
|
await expect(migratedCard.getByText("Migration required", { exact: true })).not.toBeVisible();
|
|
await migratedCard.getByRole("button", { name: "Actions" }).click();
|
|
await page.getByRole("menuitem", { name: "Delete Custom Widget" }).click();
|
|
const deleteDialog = page.getByRole("dialog", { name: "Delete Custom Widget" });
|
|
await deleteDialog.getByRole("button", { name: "Confirm", exact: true }).click();
|
|
await expect(page.getByText(`Widget "${legacyWidgetName}" deleted.`)).toBeVisible({ timeout: 15_000 });
|
|
|
|
expect(
|
|
await db.query.customWidgetDefinitions.findFirst({
|
|
where: eq(customWidgetDefinitions.id, legacyWidgetId),
|
|
}),
|
|
).toBeUndefined();
|
|
expect(
|
|
await db.query.legacyCustomWidgetDefinitions.findFirst({
|
|
where: eq(legacyCustomWidgetDefinitions.id, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({ id: legacyWidgetId, name: legacyWidgetName });
|
|
expect(
|
|
await db.query.legacyCustomWidgetSecrets.findFirst({
|
|
where: eq(legacyCustomWidgetSecrets.definitionId, legacyWidgetId),
|
|
}),
|
|
).toMatchObject({ definitionId: legacyWidgetId, encryptedValue });
|
|
|
|
await page.reload();
|
|
const resurfacedLegacyCard = page
|
|
.getByText(legacyWidgetName, { exact: true })
|
|
.locator("xpath=ancestor::*[contains(@class, 'mantine-Card-root')]");
|
|
await expect(resurfacedLegacyCard.getByText("Migration required", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await Promise.allSettled([browser?.close(), homarrContainer?.stop()]);
|
|
}
|
|
}, 180_000);
|
|
});
|