The small refresh-circle overlay that appeared in a widget's top-right corner during a background refetch wasn't adding useful signal and just added visual noise. Drop the isWidgetRefreshing detection, the indicator markup/CSS, and the now-dead query-key/runtime-query plumbing that only existed to power it. Updated the lazy-widgets e2e test to drop its assertions on the now -removed [data-widget-refreshing] marker; the behavior it was really verifying (cached content stays visible during a background refetch, which completes cleanly) is still covered by the existing network -level checks in the same test.
201 lines
7.2 KiB
TypeScript
201 lines
7.2 KiB
TypeScript
import { createId } from "@paralleldrive/cuid2";
|
|
import { chromium, expect } from "@playwright/test";
|
|
import type { Route } from "@playwright/test";
|
|
import { eq } from "drizzle-orm";
|
|
import { stringify } from "superjson";
|
|
import { describe, 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 adminCredentials = {
|
|
username: "admin",
|
|
password: "Comp(exP4sswOrd",
|
|
};
|
|
|
|
describe("lazy widget application graph", () => {
|
|
test("renders an authenticated board and preserves first-use UI behavior after reload", async () => {
|
|
const { db, localMountPath } = await createSqliteDbFileAsync();
|
|
const { userId } = await seedAdminUserAsync(db, adminCredentials);
|
|
const boardId = createId();
|
|
const layoutId = createId();
|
|
const sectionId = createId();
|
|
const clockItemId = createId();
|
|
const downloadsItemId = createId();
|
|
const mockIntegrationId = createId();
|
|
|
|
await db.insert(sqliteSchema.boards).values({
|
|
id: boardId,
|
|
name: "lazy-widget-board",
|
|
creatorId: userId,
|
|
});
|
|
await db.insert(sqliteSchema.layouts).values({
|
|
id: layoutId,
|
|
name: "Base",
|
|
boardId,
|
|
columnCount: 12,
|
|
});
|
|
await db.insert(sqliteSchema.sections).values({
|
|
id: sectionId,
|
|
boardId,
|
|
kind: "empty",
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
});
|
|
await db.insert(sqliteSchema.integrations).values({
|
|
id: mockIntegrationId,
|
|
name: "Mock downloads",
|
|
url: "http://mock.local",
|
|
kind: "mock",
|
|
});
|
|
await db.insert(sqliteSchema.items).values([
|
|
{
|
|
id: clockItemId,
|
|
boardId,
|
|
kind: "clock",
|
|
options: stringify({ showSeconds: true }),
|
|
},
|
|
{
|
|
id: downloadsItemId,
|
|
boardId,
|
|
kind: "downloads",
|
|
options: stringify({}),
|
|
},
|
|
]);
|
|
await db.insert(sqliteSchema.itemLayouts).values([
|
|
{
|
|
itemId: clockItemId,
|
|
sectionId,
|
|
layoutId,
|
|
xOffset: 0,
|
|
yOffset: 0,
|
|
width: 4,
|
|
height: 2,
|
|
},
|
|
{
|
|
itemId: downloadsItemId,
|
|
sectionId,
|
|
layoutId,
|
|
xOffset: 0,
|
|
yOffset: 2,
|
|
width: 8,
|
|
height: 5,
|
|
},
|
|
]);
|
|
await db.insert(sqliteSchema.integrationItems).values({
|
|
itemId: downloadsItemId,
|
|
integrationId: mockIntegrationId,
|
|
});
|
|
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
|
|
|
const homarrContainer = await createHomarrContainer({
|
|
environment: {
|
|
AUTH_PROVIDERS: "credentials",
|
|
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
|
},
|
|
mounts: {
|
|
"/appdata": localMountPath,
|
|
},
|
|
}).start();
|
|
|
|
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
|
const browser = await chromium.launch();
|
|
const context = await browser.newContext({ serviceWorkers: "block" });
|
|
const page = await context.newPage();
|
|
const pageErrors: Error[] = [];
|
|
const hydrationErrors: string[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error));
|
|
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
|
|
hydrationErrors.push(message.text());
|
|
}
|
|
});
|
|
let releaseRefresh: (() => void) | undefined;
|
|
|
|
try {
|
|
await loginAsync({ page, baseUrl, credentials: adminCredentials });
|
|
|
|
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
|
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
|
const clockWidget = page.locator(
|
|
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
|
);
|
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
|
timeout: 30_000,
|
|
});
|
|
const downloadsWidget = page
|
|
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
|
.filter({ visible: true })
|
|
.first();
|
|
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
|
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
|
|
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
|
timeout: 15_000,
|
|
});
|
|
await page.keyboard.press("Control+K");
|
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-feedback]")).toHaveCount(0);
|
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).toBeVisible({ timeout: 15_000 });
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
|
|
|
let refreshRequestStarted = false;
|
|
const refreshGate = new Promise<void>((resolve) => {
|
|
releaseRefresh = resolve;
|
|
});
|
|
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
|
const delayDownloadsRefresh = async (route: Route) => {
|
|
refreshRequestStarted = true;
|
|
await refreshGate;
|
|
await route.continue();
|
|
};
|
|
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
|
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--", {
|
|
timeout: 30_000,
|
|
});
|
|
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
|
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
|
|
|
const refreshedDownloadsResponse = page.waitForResponse(
|
|
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
|
);
|
|
releaseRefresh();
|
|
releaseRefresh = undefined;
|
|
await refreshedDownloadsResponse;
|
|
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
|
expect(pageErrors).toEqual([]);
|
|
expect(hydrationErrors).toEqual([]);
|
|
|
|
const serviceWorkerContext = await browser.newContext();
|
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
|
try {
|
|
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
await serviceWorkerPage.evaluate(async () => {
|
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
return registrations[0]?.scope ?? null;
|
|
}),
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(`${baseUrl}/`);
|
|
} finally {
|
|
await serviceWorkerContext.close();
|
|
}
|
|
} finally {
|
|
releaseRefresh?.();
|
|
await browser.close();
|
|
await homarrContainer.stop();
|
|
}
|
|
}, 180_000);
|
|
});
|