fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

+142
View File
@@ -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
View File
@@ -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);
});