import { test, expect } from "@playwright/test"; import { readJson, resetData, writeSettings } from "./helpers"; // Covers the global passive auto-refresh: the Settings field (default 5s, // persists, accepts 0=off, rejects out-of-range) and the runtime behavior — // with a short interval the page re-fetches its server data on its own (the // sidebar admin title, rendered by the root layout from settings, updates // without a reload); with 0 it never does. test.describe("auto-refresh setting", () => { test.beforeEach(async () => { await resetData("empty"); }); test("defaults to 5s and persists a new value", async ({ page }) => { await page.goto("/settings"); await expect(page.getByLabel(/auto-refresh interval/i)).toHaveValue("5"); await page.getByLabel(/auto-refresh interval/i).fill("12"); await page.getByRole("button", { name: /save settings/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const saved = await readJson<{ autoRefreshIntervalSeconds?: number }>( "test-settings.json", ); expect(saved.autoRefreshIntervalSeconds).toBe(12); await page.reload(); await expect(page.getByLabel(/auto-refresh interval/i)).toHaveValue("12"); }); test("accepts 0 to disable", async ({ page }) => { await page.goto("/settings"); await page.getByLabel(/auto-refresh interval/i).fill("0"); await page.getByRole("button", { name: /save settings/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const saved = await readJson<{ autoRefreshIntervalSeconds?: number }>( "test-settings.json", ); expect(saved.autoRefreshIntervalSeconds).toBe(0); }); test("rejects out-of-range interval", async ({ page }) => { await page.goto("/settings"); await page.getByLabel(/auto-refresh interval/i).fill("9999"); await page.getByRole("button", { name: /save settings/i }).click(); await expect(page.locator("form").getByRole("alert")).toContainText( "between", ); }); }); test.describe("auto-refresh behavior", () => { test("re-fetches server data on the interval without a reload", async ({ page, }) => { await resetData("empty"); // Short cadence so the test isn't slow; the sidebar title comes from the // root layout's getSettings(), so a router.refresh() repaints it. await writeSettings({ autoRefreshIntervalSeconds: 1, adminTitle: "Before Title", }); await page.goto("/"); const sidebar = page.locator("aside"); await expect(sidebar.getByText("Before Title")).toBeVisible(); // Change the title out of band. No navigation, no reload — only the passive // refresher should bring it in. await writeSettings({ autoRefreshIntervalSeconds: 1, adminTitle: "After Title", }); await expect(sidebar.getByText("After Title")).toBeVisible({ timeout: 8_000, }); }); // THE regression test for the auto-refresh rewrite. // // The refresher used to call router.refresh() unconditionally on every tick — // a full server re-render of the page tree whether or not anything had // changed, on every route, forever. It now polls a cheap change token first // and only refreshes when that token moves. With nothing writing to the data // directory, the correct number of tree refreshes over several seconds is // exactly zero, while the pulse keeps ticking. test("does not refresh the tree when nothing has changed", async ({ page, }) => { await resetData("empty"); await writeSettings({ autoRefreshIntervalSeconds: 1, adminTitle: "Quiet Title", }); let rscRefreshes = 0; let pulses = 0; page.on("request", (req) => { const url = new URL(req.url()); // The client polls /api/view/pulse; /api/pulse is a rewrite onto it // for pollers we cannot update. This counts what the browser SENDS. if (url.pathname === "/api/view/pulse") { pulses++; return; } // A tree refresh is an RSC request carrying the router state tree and no // prefetch marker. Prefetches are excluded deliberately: they are cheap, // they are what makes navigation instant, and they are not what this test // is about. if (!url.searchParams.has("_rsc")) return; const headers = req.headers(); if (headers["next-router-prefetch"]) return; if (headers["next-router-segment-prefetch"]) return; rscRefreshes++; }); await page.goto("/"); await expect(page.locator("aside").getByText("Quiet Title")).toBeVisible(); // Settle past the initial load before measuring. The very first pulse // necessarily changes the token (the client starts with none), and that one // refresh is additionally deferred by the post-navigation quiet window — so // it lands a beat after the page appears. This test is about STEADY state, // so that startup refresh belongs in the baseline, not in the measurement. await page.waitForTimeout(3_000); const baseline = rscRefreshes; pulses = 0; await page.waitForTimeout(5_000); expect( rscRefreshes - baseline, "a quiet page must not re-render the server tree", ).toBe(0); expect(pulses, "the pulse must still be polling").toBeGreaterThanOrEqual(4); }); test("does not auto-refresh when disabled (0)", async ({ page }) => { await resetData("empty"); await writeSettings({ autoRefreshIntervalSeconds: 0, adminTitle: "Before Title", }); await page.goto("/"); const sidebar = page.locator("aside"); await expect(sidebar.getByText("Before Title")).toBeVisible(); await writeSettings({ autoRefreshIntervalSeconds: 0, adminTitle: "After Title", }); // Give a disabled refresher ample time to (not) fire, then confirm the page // still shows the old title — but a manual reload does pick up the change, // proving it was persisted and simply not auto-applied. await page.waitForTimeout(3_000); await expect(sidebar.getByText("Before Title")).toBeVisible(); await page.reload(); await expect(sidebar.getByText("After Title")).toBeVisible(); }); });