feat: rebuild onboarding experience
This commit is contained in:
1 parent
d091362db2
commit
e41605640b
249 files changed
+8216
-14060
No files matched your search
+107
-8
@@ -1,12 +1,102 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium } from "@playwright/test";
|
||||
import { describe, test } from "vitest";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
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";
|
||||
|
||||
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, `onboarding-${name}.png`), fullPage: true });
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
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 });
|
||||
|
||||
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`);
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test("Credentials onboarding should be successful", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
@@ -25,7 +115,15 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
|
||||
const competingContext = await browser.newContext();
|
||||
const claimResponse = await competingContext.request.post(
|
||||
`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
|
||||
);
|
||||
expect(claimResponse.status()).toBe(423);
|
||||
await competingContext.close();
|
||||
|
||||
await actions.processUserStepAsync({
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
@@ -43,7 +141,7 @@ describe("Onboarding", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("External provider onboarding setup should be successful", async () => {
|
||||
test("External provider onboarding requires sign-in before privileged setup", async () => {
|
||||
// Arrange
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
@@ -69,17 +167,18 @@ describe("Onboarding", () => {
|
||||
try {
|
||||
// Act
|
||||
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
|
||||
await actions.startOnboardingAsync("scratch");
|
||||
await actions.startOnboardingAsync();
|
||||
await actions.processExternalGroupStepAsync({
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor();
|
||||
expect(await page.getByRole("link", { name: "Sign in" }).getAttribute("href")).toBe(
|
||||
"/auth/login?callbackUrl=%2Finit",
|
||||
);
|
||||
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
|
||||
await assertions.assertDbOnboardingStepAsync("finish");
|
||||
await assertions.assertDbOnboardingStepAsync("setup");
|
||||
} finally {
|
||||
await Promise.all([browser.close(), homarrContainer.stop()]);
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ export class OnboardingActions {
|
||||
}
|
||||
}
|
||||
|
||||
public async startOnboardingAsync(type: "scratch" | "before 1.0") {
|
||||
await this.page.locator("button", { hasText: type }).click();
|
||||
public async startOnboardingAsync() {
|
||||
await this.page.getByRole("button", { name: "Get started" }).click();
|
||||
}
|
||||
|
||||
public async processUserStepAsync(input: { username: string; password: string; confirmPassword: string }) {
|
||||
await this.page.waitForSelector("text=administrator user");
|
||||
await this.page.getByRole("heading", { name: "Create your administrator" }).waitFor();
|
||||
|
||||
await this.page.getByLabel("Username").fill(input.username);
|
||||
await this.page.getByLabel("Administrator username").fill(input.username);
|
||||
await this.page.getByLabel("Password", { exact: true }).fill(input.password);
|
||||
await this.page.getByLabel("Confirm password").fill(input.confirmPassword);
|
||||
|
||||
@@ -42,18 +42,16 @@ export class OnboardingActions {
|
||||
}
|
||||
|
||||
public async processExternalGroupStepAsync(input: { name: string }) {
|
||||
await this.page.waitForSelector("text=external provider");
|
||||
await this.page.locator("input").fill(input.name);
|
||||
await this.page.getByRole("heading", { name: "Connect your administrator group" }).waitFor();
|
||||
await this.page.getByLabel("External administrator group").fill(input.name);
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processSettingsStepAsync() {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
await this.page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
|
||||
}
|
||||
|
||||
public async processIntegrationsStepAsync() {
|
||||
await this.page.waitForSelector("text=integrations you'd like to connect");
|
||||
await this.page.locator("button", { hasText: "Skip for now" }).click();
|
||||
await this.page.getByRole("button", { name: "Build my board" }).click();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
|
||||
await this.page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse
|
||||
if (existingOnboarding) {
|
||||
await db.update(sqliteSchema.onboarding).set({
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
} else {
|
||||
await db.insert(sqliteSchema.onboarding).values({
|
||||
id: createId(),
|
||||
step: "finish",
|
||||
previousStep: "settings",
|
||||
previousStep: "setup",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user