fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { stringify as stringifySuperJSON } from "superjson";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
|
||||
const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
if (!directory) return;
|
||||
await mkdir(directory, { recursive: true });
|
||||
await page.screenshot({ path: path.join(directory, `manage-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
describe("Management collections", () => {
|
||||
test("renders unified collections and a compact board preview on desktop and mobile", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId } = await seedAdminUserAsync(db, credentials);
|
||||
const boardId = "manage-board";
|
||||
const layoutId = "manage-layout";
|
||||
const sectionId = "manage-section";
|
||||
const appId = "manage-app";
|
||||
|
||||
await db.insert(sqliteSchema.apps).values({
|
||||
id: appId,
|
||||
name: "Very long service name used to verify responsive management rows",
|
||||
description: "A useful service with enough detail to exercise the shared collection description slot.",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://service.example.internal/a/very/long/reverse/proxy/path/that/must/not/overflow",
|
||||
pingUrl: "https://service.example.internal/health",
|
||||
});
|
||||
await db.insert(sqliteSchema.integrations).values({
|
||||
id: "manage-integration",
|
||||
name: "Living room Sonarr",
|
||||
url: "https://sonarr.example.internal/a/long/reverse/proxy/path",
|
||||
kind: "sonarr",
|
||||
appId,
|
||||
});
|
||||
await db.insert(sqliteSchema.searchEngines).values({
|
||||
id: "manage-search-engine",
|
||||
iconUrl: "/favicon.ico",
|
||||
name: "Internal documentation",
|
||||
short: "docs",
|
||||
description: "Search private documentation from Spotlight.",
|
||||
urlTemplate: "https://docs.example.internal/search?q=%s",
|
||||
type: "generic",
|
||||
});
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
name: "management-preview",
|
||||
creatorId: userId,
|
||||
isPublic: false,
|
||||
});
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
boardId,
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
role: "base",
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: sectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: "manage-clock",
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringifySuperJSON({ is24HourFormat: true }),
|
||||
},
|
||||
{
|
||||
id: "manage-app-item",
|
||||
boardId,
|
||||
kind: "app",
|
||||
options: stringifySuperJSON({ appId, openInNewTab: true, showTitle: true }),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{ itemId: "manage-clock", sectionId, layoutId, xOffset: 0, yOffset: 0, width: 2, height: 2 },
|
||||
{ itemId: "manage-app-item", sectionId, layoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 },
|
||||
]);
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/manage/apps" });
|
||||
await page.getByRole("heading", { name: "Apps", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured apps" }).getByRole("listitem").count()).toBe(1);
|
||||
const editApp = page.getByRole("link", { name: /Edit Very long service name/ });
|
||||
await editApp.focus();
|
||||
expect(await editApp.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
expect((await editApp.boundingBox())?.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/integrations`);
|
||||
await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured integrations" }).getByRole("listitem").count()).toBe(1);
|
||||
expect(await page.getByText("Sonarr", { exact: true }).isVisible()).toBe(true);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/search-engines`);
|
||||
await page.getByRole("heading", { name: "Search engines", exact: true }).waitFor();
|
||||
expect(await page.getByRole("list", { name: "Configured search engines" }).getByRole("listitem").count()).toBe(1);
|
||||
|
||||
await page.goto(`${baseUrl}/manage/boards`);
|
||||
await page.getByRole("heading", { name: "Your boards", exact: true }).waitFor();
|
||||
const preview = page.getByRole("img", { name: "Layout preview for management-preview, with 2 items" });
|
||||
await preview.waitFor();
|
||||
expect(await preview.locator('[data-lane="main"]').count()).toBe(1);
|
||||
await captureScreenshotAsync(page, "desktop-boards");
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
for (const route of ["apps", "integrations", "search-engines", "boards"]) {
|
||||
await page.goto(`${baseUrl}/manage/${route}`);
|
||||
await page.locator("h1").waitFor();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
}
|
||||
await captureScreenshotAsync(page, "mobile-boards");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
+305
-66
@@ -1,14 +1,20 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import AdmZip from "adm-zip";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { OnboardingActions } from "./shared/actions/onboarding-actions";
|
||||
import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim();
|
||||
@@ -17,85 +23,197 @@ const captureScreenshotAsync = async (page: Page, name: string) => {
|
||||
await page.screenshot({ path: path.join(directory, `onboarding-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
const createRestoreArchiveAsync = async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
|
||||
await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
|
||||
await db.insert(sqliteSchema.layouts).values({
|
||||
id: "restored-base",
|
||||
boardId: "restored-board",
|
||||
name: "Base",
|
||||
role: "base",
|
||||
columnCount: 10,
|
||||
breakpoint: 768,
|
||||
});
|
||||
await db.insert(sqliteSchema.sections).values({
|
||||
id: "restored-root",
|
||||
boardId: "restored-board",
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
db.$client.close();
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
homarrVersion: "e2e",
|
||||
exportedAt: new Date().toISOString(),
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: "0".repeat(64),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
};
|
||||
|
||||
describe("Onboarding", () => {
|
||||
test.each([
|
||||
{ name: "mobile", width: 320, height: 740 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
])("Onboarding is usable at the $name viewport", async ({ name, width, height }) => {
|
||||
])(
|
||||
"Onboarding is usable at the $name viewport",
|
||||
async ({ name, width, height }) => {
|
||||
const { localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 });
|
||||
|
||||
const serverOrigin = page.getByLabel("Domain, IP, or reverse-proxy path");
|
||||
await serverOrigin.fill("https://home.example/homarr");
|
||||
await page.getByLabel("Default language").click();
|
||||
await page.getByRole("option", { name: /Français/ }).click();
|
||||
expect(await serverOrigin.inputValue()).toBe("https://home.example/homarr");
|
||||
expect(await page.getByRole("heading", { name: "Start with familiar defaults" }).isVisible()).toBe(true);
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
await captureScreenshotAsync(page, `${name}-studio`);
|
||||
|
||||
const activeSection = page.locator('[aria-current="step"]');
|
||||
await activeSection.focus();
|
||||
await activeSection.press("ArrowLeft");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("ArrowRight");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await activeSection.press("Home");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
|
||||
const sections = [
|
||||
["Discover", "See what this installation can actually reach"],
|
||||
["Connect", "Connect what makes the board useful"],
|
||||
["Board", "Shape the board before it is built"],
|
||||
["Extend", "Add intelligence when it helps"],
|
||||
] as const;
|
||||
for (const [section, heading] of sections) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.getByRole("heading", { name: heading }).waitFor();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^Board/ }).click();
|
||||
const boardPreview = page.getByLabel("Live preview of the first board layout");
|
||||
expect(await boardPreview.getAttribute("data-layout-role")).toBe("base");
|
||||
expect(await boardPreview.getAttribute("data-layout-columns")).toBe("10");
|
||||
await page.getByRole("radio", { name: "Mobile", exact: true }).click();
|
||||
expect(await boardPreview.getAttribute("data-layout-role")).toBe("mobile");
|
||||
expect(await boardPreview.getAttribute("data-layout-columns")).toBe("3");
|
||||
await page.getByRole("radio", { name: "Wide", exact: true }).click();
|
||||
await page.getByRole("radio", { name: "Base", exact: true }).click();
|
||||
expect(await boardPreview.getAttribute("data-layout-columns")).toBe("12");
|
||||
|
||||
await page.getByRole("button", { name: /^Connect/ }).click();
|
||||
const sonarr = page.locator('button[aria-label="Sonarr"]');
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-integration-details`);
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor({ state: "detached" });
|
||||
|
||||
await activeSection.focus();
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-review`);
|
||||
|
||||
await page.getByRole("button", { name: "Build my board" }).click();
|
||||
await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
await captureScreenshotAsync(page, `${name}-finish`);
|
||||
|
||||
await page.getByRole("link", { name: "Open my board" }).click();
|
||||
await page.waitForURL(/\/boards\/dashboard(?:[/?#]|$)/);
|
||||
const boardCanvas = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]');
|
||||
await boardCanvas.waitFor({ timeout: 30_000 });
|
||||
expect(await boardCanvas.locator("[data-grid-item-id]").count()).toBeGreaterThanOrEqual(3);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
|
||||
test("SQLite onboarding restores a validated backup and survives the restart", async () => {
|
||||
const restoreArchive = await createRestoreArchiveAsync();
|
||||
const invalidArchive = new AdmZip();
|
||||
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
|
||||
const { localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
let initialContainerStopped = false;
|
||||
let restoredContainer: StartedTestContainer | undefined;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
const start = page.getByRole("button", { name: "Get started" });
|
||||
await start.focus();
|
||||
await start.press("Enter");
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${initialBaseUrl}/init`);
|
||||
await page.getByRole("button", { name: "Come from a backup" }).click();
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: "missing-metadata.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: invalidArchive.toBuffer(),
|
||||
});
|
||||
await page.getByText("Invalid backup: missing metadata.json").waitFor();
|
||||
await page.getByRole("button", { name: "Change file" }).click();
|
||||
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 });
|
||||
await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive });
|
||||
await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 });
|
||||
expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true);
|
||||
await page.getByRole("button", { name: "Continue to restore" }).click();
|
||||
await page.getByPlaceholder("I understand").fill("I understand");
|
||||
const restoreResponse = page.waitForResponse(
|
||||
(response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST",
|
||||
);
|
||||
await page.getByRole("button", { name: "Restore Database" }).click();
|
||||
expect((await restoreResponse).status()).toBe(200);
|
||||
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||
).toBe(true);
|
||||
await captureScreenshotAsync(page, `${name}-studio`);
|
||||
|
||||
const activeSection = page.locator('[aria-current="step"]');
|
||||
await activeSection.focus();
|
||||
await activeSection.press("ArrowLeft");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("ArrowRight");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await activeSection.press("Home");
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
|
||||
const sections = [
|
||||
["Discover", "See what this installation can actually reach"],
|
||||
["Connect", "Connect what makes the board useful"],
|
||||
["Board", "Shape the board before it is built"],
|
||||
["Extend", "Add intelligence when it helps"],
|
||||
] as const;
|
||||
for (const [section, heading] of sections) {
|
||||
await page.getByRole("button", { name: new RegExp(`^${section}`) }).click();
|
||||
await page.getByRole("heading", { name: heading }).waitFor();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^Connect/ }).click();
|
||||
const sonarr = page.locator('button[aria-label="Sonarr"]');
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-integration-details`);
|
||||
await sonarr.click();
|
||||
await page.getByLabel("Service URL").waitFor({ state: "detached" });
|
||||
|
||||
await activeSection.focus();
|
||||
await activeSection.press("End");
|
||||
await page.getByRole("heading", { name: "One board, built around your services" }).waitFor();
|
||||
await captureScreenshotAsync(page, `${name}-review`);
|
||||
|
||||
await page.getByRole("button", { name: "Build my board" }).click();
|
||||
await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
await captureScreenshotAsync(page, `${name}-finish`);
|
||||
await initialContainer.stop();
|
||||
initialContainerStopped = true;
|
||||
restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${restoredBaseUrl}/boards/restored`);
|
||||
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
|
||||
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
await browser.close();
|
||||
if (!initialContainerStopped) await initialContainer.stop();
|
||||
if (restoredContainer) await restoredContainer.stop();
|
||||
}
|
||||
}, 90_000);
|
||||
}, 120_000);
|
||||
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
@@ -141,6 +259,37 @@ describe("Onboarding", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
await page.goto(`${baseUrl}/init`);
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
await page.route("**/api/auth/callback/credentials**", async (route) => {
|
||||
await route.fulfill({ status: 500, contentType: "application/json", body: '{"error":"forced-e2e-failure"}' });
|
||||
});
|
||||
await page.getByLabel("Administrator username").fill("admin");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd");
|
||||
await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd");
|
||||
await page.locator("button[type='submit']").click();
|
||||
|
||||
await page.getByRole("heading", { name: "Administrator account created" }).waitFor();
|
||||
const recovery = page.getByRole("link", { name: "Sign in and continue" });
|
||||
expect(await recovery.getAttribute("href")).toBe("/auth/login?callbackUrl=/init");
|
||||
expect(await recovery.evaluate((element) => element === document.activeElement)).toBe(true);
|
||||
expect((await db.query.users.findMany()).length).toBe(1);
|
||||
expect((await db.query.onboarding.findFirst())?.step).toBe("setup");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding requires sign-in before privileged setup", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
@@ -183,4 +332,94 @@ describe("Onboarding", () => {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("Legacy setup requires sign-in without exposing private board metadata", async () => {
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, credentials);
|
||||
await db.update(sqliteSchema.onboarding).set({ step: "import" as never });
|
||||
const privateBoardName = "private-board-must-not-leak";
|
||||
await db.insert(sqliteSchema.boards).values({ id: createId(), name: privateBoardName, isPublic: false });
|
||||
|
||||
const privateWorkshopApiUrl = "http://internal-workshop-api.invalid";
|
||||
const privateWorkshopWebUrl = "http://internal-workshop-web.invalid";
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
WORKSHOP_API_URL: privateWorkshopApiUrl,
|
||||
WORKSHOP_WEB_URL: privateWorkshopWebUrl,
|
||||
},
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const response = await context.request.get(`${baseUrl}/init`);
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(response.url()).toBe(`${baseUrl}/init`);
|
||||
const responseBody = await response.text();
|
||||
expect(responseBody).not.toContain(privateBoardName);
|
||||
expect(responseBody).not.toContain(privateWorkshopApiUrl);
|
||||
expect(responseBody).not.toContain(privateWorkshopWebUrl);
|
||||
|
||||
await page.goto(`${baseUrl}/init`);
|
||||
await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor();
|
||||
expect(await page.getByRole("link", { name: "Sign in and resume" }).getAttribute("href")).toBe(
|
||||
"/auth/login?callbackUrl=%2Finit",
|
||||
);
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
|
||||
await page.getByRole("button", { name: "Get started" }).click();
|
||||
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Assistant setup restores saved configuration and accepts a manual model", async () => {
|
||||
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, credentials);
|
||||
await db.update(sqliteSchema.onboarding).set({ step: "setup", previousStep: "group" });
|
||||
await db.insert(sqliteSchema.assistantConfigurations).values({
|
||||
id: "default",
|
||||
enabled: true,
|
||||
provider: "custom",
|
||||
baseUrl: "http://assistant.local/v1",
|
||||
modelDiscoveryPath: null,
|
||||
modelId: "local-model",
|
||||
});
|
||||
|
||||
const configuredWorkshopApiUrl = "http://authenticated-workshop-api.invalid";
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { WORKSHOP_API_URL: configuredWorkshopApiUrl },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
|
||||
expect(await page.locator('meta[name="homarr-workshop-api-url"]').getAttribute("content")).toBe(
|
||||
configuredWorkshopApiUrl,
|
||||
);
|
||||
await page.getByRole("button", { name: /^Extend/ }).click();
|
||||
await page.getByRole("heading", { name: "Add intelligence when it helps" }).waitFor();
|
||||
await page.getByLabel("Provider URL").waitFor();
|
||||
|
||||
const provider = page.getByRole("combobox", { name: "Provider", exact: true });
|
||||
await expect.poll(() => provider.inputValue(), { timeout: 10_000 }).toBe("custom");
|
||||
expect(await page.getByLabel("Provider URL").inputValue()).toBe("http://assistant.local/v1");
|
||||
expect(await page.getByLabel("Default model").inputValue()).toBe("local-model");
|
||||
expect(await page.getByText("Model discovery is unavailable.").count()).toBe(1);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
Reference in new issue
Block a user