A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { test, expect, type Page, type Request, type Response } from "@playwright/test";
|
|
import { MEMBER_STORAGE, openApp } from "./helpers";
|
|
|
|
// What a plain member (not an admin) sees. The rest of the suite runs as the admin, so without this
|
|
// spec nothing end to end holds the member's shell: the backend refuses the admin routes (pytest),
|
|
// but a member shown an admin entry, sent to an admin page or met by a refused request would pass.
|
|
test.use({ storageState: MEMBER_STORAGE });
|
|
|
|
const ADMIN_PAGES = ["scheduler", "config", "users", "activity"];
|
|
// The pages every member has; Plex joins them only where the instance enables it.
|
|
const CORE = [
|
|
"home",
|
|
"feed",
|
|
"channels",
|
|
"playlists",
|
|
"notifications",
|
|
"files",
|
|
"stats",
|
|
"settings",
|
|
];
|
|
|
|
const isApi = (url: string) => new URL(url).pathname.startsWith("/api/");
|
|
|
|
/** Collect every admin call and every failed API answer the page gets while `run` goes. `run` is
|
|
* handed `settled`, which waits until no API request of the page is in flight: an error state
|
|
* renders only once its request has answered, and a check made before that passes on a page that
|
|
* is still loading. */
|
|
async function watchRequests(
|
|
page: Page,
|
|
run: (settled: () => Promise<void>) => Promise<void>,
|
|
): Promise<string[]> {
|
|
const bad: string[] = [];
|
|
const pending = new Set<Request>();
|
|
const onRequest = (r: Request) => {
|
|
if (!isApi(r.url())) return;
|
|
pending.add(r);
|
|
if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`);
|
|
};
|
|
const onDone = (r: Request) => pending.delete(r);
|
|
const onResponse = (r: Response) => {
|
|
if (isApi(r.url()) && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
|
|
};
|
|
const settled = () => expect.poll(() => pending.size, { timeout: 15_000 }).toBe(0);
|
|
page.on("request", onRequest);
|
|
page.on("requestfinished", onDone);
|
|
page.on("requestfailed", onDone);
|
|
page.on("response", onResponse);
|
|
try {
|
|
await run(settled);
|
|
await settled();
|
|
} finally {
|
|
page.off("request", onRequest);
|
|
page.off("requestfinished", onDone);
|
|
page.off("requestfailed", onDone);
|
|
page.off("response", onResponse);
|
|
}
|
|
return bad;
|
|
}
|
|
|
|
test.describe("a member", () => {
|
|
test("gets no admin entry, and every page of the rail opens without an error", async ({
|
|
page,
|
|
}) => {
|
|
const bad = await watchRequests(page, async (settled) => {
|
|
await openApp(page);
|
|
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
|
|
|
const rail = page
|
|
.getByRole("navigation", { name: "Primary navigation" })
|
|
.locator('[data-testid^="nav-"]');
|
|
const ids = (await rail.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid"))))
|
|
.filter((id): id is string => id !== null)
|
|
.map((id) => id.replace(/^nav-/, ""));
|
|
expect(ids).toEqual(expect.arrayContaining(CORE));
|
|
expect(ids.filter((id) => ADMIN_PAGES.includes(id))).toEqual([]);
|
|
|
|
for (const id of ids) {
|
|
await page.getByTestId(`nav-${id}`).click();
|
|
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
|
|
await settled();
|
|
// An error state is a `role=alert` StateMessage; a page that failed to load shows one.
|
|
await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0);
|
|
}
|
|
});
|
|
expect(bad).toEqual([]);
|
|
});
|
|
|
|
test("a link to an admin page opens no admin page", async ({ page }) => {
|
|
for (const id of ADMIN_PAGES) {
|
|
const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`));
|
|
expect(bad, id).toEqual([]);
|
|
await expect(page.getByTestId("nav-admin")).toHaveCount(0);
|
|
// The member lands on the feed instead, and the rail says so.
|
|
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
|
|
}
|
|
});
|
|
});
|