diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 1129f5c..20f9161 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -13,6 +13,8 @@ ## Synchronization +- Server-rendered visibility does not prove hydration. Call `waitForDashboardReady(page)` before dashboard input, keyboard/focus behavior, or client-router interaction after a full navigation or reload. Shared interaction helpers may own this wait. Preserve deliberate pre-hydration assertions and server-rendered-only checks. + - Prefer route barriers (`page.route`, `route.fetch`, `route.fulfill`) and `Promise.withResolvers()` over sleeps. - Match API waits by HTTP method plus exact absolute URL, especially for period/date/history race tests. - Verify state both while a request is blocked and after it is released. diff --git a/tests/e2e/dashboard-calendar.spec.ts b/tests/e2e/dashboard-calendar.spec.ts index cdbd84b..38158d0 100644 --- a/tests/e2e/dashboard-calendar.spec.ts +++ b/tests/e2e/dashboard-calendar.spec.ts @@ -1,7 +1,12 @@ import { expect, test } from "@playwright/test"; import { configureDashboardDayEntryE2E } from "./dashboard-day-entry-helpers"; import { seedPeriod } from "./helpers/db"; -import { addDays, getBaseUrl, getCurrentJstDate } from "./dashboard-shared"; +import { + addDays, + getBaseUrl, + getCurrentJstDate, + waitForDashboardReady, +} from "./dashboard-shared"; configureDashboardDayEntryE2E(); @@ -16,6 +21,7 @@ test("moves one roving calendar focus across months with keyboard", async ({ budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-keys`); + await waitForDashboardReady(page); const januaryEnd = page.getByTestId("calendar-day-2026-01-31"); const februaryStart = page.getByTestId("calendar-day-2026-02-01"); @@ -77,6 +83,7 @@ test("calendar handoff opens the focused date once with Enter and retains select budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-keys`); + await waitForDashboardReady(page); const date = "2026-02-01"; const day = page.getByTestId(`calendar-day-${date}`); @@ -114,6 +121,7 @@ test("calendar handoff opens the focused date once with Space", async ({ budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-keys`); + await waitForDashboardReady(page); const date = "2026-02-01"; const day = page.getByTestId(`calendar-day-${date}`); @@ -150,6 +158,7 @@ test("keyboard keeps disabled calendar dates focusable without opening history", budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-current`); + await waitForDashboardReady(page); const periodSummaryBarrier = Promise.withResolvers(); const nextSummaryUrl = `${getBaseUrl()}/api/periods/p-calendar-next`; @@ -213,6 +222,7 @@ test("keeps selection separate from keyboard focus and resets it for another per budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-current`); + await waitForDashboardReady(page); const todayDay = page.getByTestId(`calendar-day-${today}`); const tomorrowDay = page.getByTestId(`calendar-day-${addDays(today, 1)}`); @@ -257,6 +267,7 @@ test("keeps future allowance unchanged while future calendar input remains avail budgetYen: 90000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-future`); + await waitForDashboardReady(page); const futureDay = page.getByTestId(`calendar-day-${futureStart}`); await expect(page.getByTestId("today-food-allowance")).toContainText("0 円"); @@ -279,6 +290,7 @@ test("blocks calendar activation while a period save is in flight, then restores budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-save`); + await waitForDashboardReady(page); const saveBarrier = Promise.withResolvers(); const periodUrl = `${getBaseUrl()}/api/periods/p-calendar-save`; @@ -340,6 +352,7 @@ test("keeps the calendar inside every supported viewport without clipping state ], }); await page.goto(`${getBaseUrl()}/?periodId=p-calendar-responsive`); + await waitForDashboardReady(page); const todayDay = page.getByTestId(`calendar-day-${today}`); await todayDay.click(); await page diff --git a/tests/e2e/dashboard-create-period.spec.ts b/tests/e2e/dashboard-create-period.spec.ts index 760534e..55a5f4f 100644 --- a/tests/e2e/dashboard-create-period.spec.ts +++ b/tests/e2e/dashboard-create-period.spec.ts @@ -5,6 +5,7 @@ import { getBaseUrl, getCurrentJstDate, resetTestData, + waitForDashboardReady, } from "./dashboard-shared"; test.describe.configure({ mode: "serial", timeout: 120_000 }); @@ -24,6 +25,7 @@ for (const failureStage of ["summary", "list"] as const) { let posts = 0; let gets = 0; await page.goto(getBaseUrl()); + await waitForDashboardReady(page); page.on("request", (request) => { if (request.method() === "POST") posts += 1; }); @@ -97,6 +99,7 @@ for (const variant of ["initial", "additional"] as const) { }); } await page.goto(getBaseUrl()); + await waitForDashboardReady(page); if (variant === "additional") { await page.getByText("次の予算期間を作成する").click(); } @@ -156,6 +159,7 @@ test("guards duplicate creation through a held POST and list GET", async ({ let posts = 0; let gets = 0; await page.goto(getBaseUrl()); + await waitForDashboardReady(page); await page.route(`${getBaseUrl()}/api/periods`, async (route) => { if (route.request().method() === "POST") { posts += 1; @@ -221,6 +225,7 @@ test("additional recovery does not reopen or steal focus after closing during re let gets = 0; let posts = 0; await page.goto(getBaseUrl()); + await waitForDashboardReady(page); page.on("request", (request) => { if (request.method() === "POST") posts += 1; }); @@ -280,6 +285,7 @@ test("validates fields in form order and submits corrected drafts with Enter", a const today = getCurrentJstDate(); const writes: string[] = []; await page.goto(getBaseUrl()); + await waitForDashboardReady(page); page.on("request", (request) => { if (["POST", "PUT"].includes(request.method())) writes.push(request.url()); }); @@ -326,6 +332,7 @@ test("initial cancel restores defaults and clears field errors without writing", }, testInfo) => { const today = getCurrentJstDate(); await page.goto(getBaseUrl()); + await waitForDashboardReady(page); const writes: string[] = []; page.on("request", (request) => { if (["POST", "PUT"].includes(request.method())) writes.push(request.url()); @@ -372,6 +379,7 @@ test("additional cancel closes the form and keeps drafts while clearing server f budgetYen: 120000, }); await page.goto(getBaseUrl()); + await waitForDashboardReady(page); const opener = page.getByText("次の予算期間を作成する"); await opener.click(); await page.getByLabel("期間ID", { exact: true }).fill("p-retained"); diff --git a/tests/e2e/dashboard-day-entry-failure-handling.spec.ts b/tests/e2e/dashboard-day-entry-failure-handling.spec.ts index 8ed30d9..77fdeff 100644 --- a/tests/e2e/dashboard-day-entry-failure-handling.spec.ts +++ b/tests/e2e/dashboard-day-entry-failure-handling.spec.ts @@ -3,7 +3,7 @@ import { configureDashboardDayEntryE2E, seedCurrentPeriod, } from "./dashboard-day-entry-helpers"; -import { getBaseUrl } from "./dashboard-shared"; +import { getBaseUrl, waitForDashboardReady } from "./dashboard-shared"; configureDashboardDayEntryE2E(); @@ -14,6 +14,7 @@ test("rejects malformed day-entry yen values before requests", async ({ const { periodId, todayDate } = await seedCurrentPeriod(request); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); let addRequestCount = 0; await page.route( `**/api/periods/${periodId}/days/${todayDate}/add`, @@ -71,6 +72,7 @@ test("shows save error and keeps input on failed day entry update", async ({ const { periodId, todayDate } = await seedCurrentPeriod(request); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); const retryBarrier = Promise.withResolvers(); let addRequestCount = 0; await page.route( @@ -136,6 +138,7 @@ test("shows history load error while keeping the day entry modal usable", async const { periodId, todayDate } = await seedCurrentPeriod(request); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await page.route( `**/api/periods/${periodId}/days/${todayDate}/history`, async (route) => { diff --git a/tests/e2e/dashboard-day-entry-surface.spec.ts b/tests/e2e/dashboard-day-entry-surface.spec.ts index d861f66..717b446 100644 --- a/tests/e2e/dashboard-day-entry-surface.spec.ts +++ b/tests/e2e/dashboard-day-entry-surface.spec.ts @@ -5,7 +5,7 @@ import { openDayEntryAndWaitForHistory, seedCurrentPeriod, } from "./dashboard-day-entry-helpers"; -import { getBaseUrl } from "./dashboard-shared"; +import { getBaseUrl, waitForDashboardReady } from "./dashboard-shared"; configureDashboardDayEntryE2E(); @@ -190,6 +190,7 @@ test("returns focus to the calendar origin and announces an accepted save", asyn await page.setViewportSize({ width: 1280, height: 900 }); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await origin.focus(); const initialHistoryResponse = page.waitForResponse( (response) => @@ -244,6 +245,7 @@ test("falls back to the calendar heading when its origin is removed", async ({ const { periodId, todayDate } = await seedCurrentPeriod(request); const origin = page.getByTestId(`calendar-day-${todayDate}`); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await origin.focus(); await origin.press("Enter"); const modal = page.getByTestId("day-entry-modal"); diff --git a/tests/e2e/dashboard-history-surface.spec.ts b/tests/e2e/dashboard-history-surface.spec.ts index 11bfc12..5fc8732 100644 --- a/tests/e2e/dashboard-history-surface.spec.ts +++ b/tests/e2e/dashboard-history-surface.spec.ts @@ -9,7 +9,7 @@ import { configureDashboardDayEntryE2E, seedCurrentPeriod, } from "./dashboard-day-entry-helpers"; -import { getBaseUrl } from "./dashboard-shared"; +import { getBaseUrl, waitForDashboardReady } from "./dashboard-shared"; configureDashboardDayEntryE2E(); @@ -31,6 +31,7 @@ async function openHistoryWithEntries( periodId: string, date: string, ): Promise { + await waitForDashboardReady(page); const historyUrl = new URL( `/api/periods/${encodeURIComponent(periodId)}/days/${encodeURIComponent(date)}/history`, getBaseUrl(), @@ -61,6 +62,7 @@ test("keeps loading and empty history states exclusive", async ({ await route.continue(); }); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await page.getByTestId(`calendar-day-${todayDate}`).click(); await historyRequested.promise; @@ -122,6 +124,7 @@ test("keeps retry focused through a failed history GET and moves it after succes }); }); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await page.getByTestId(`calendar-day-${todayDate}`).click(); const modal = page.getByTestId("day-entry-modal"); @@ -171,6 +174,7 @@ test("loads a readable history row with wrapping memo", async ({ ); expect(response.ok()).toBe(true); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await page.getByTestId(`calendar-day-${todayDate}`).click(); const modal = page.getByTestId("day-entry-modal"); diff --git a/tests/e2e/dashboard-hydration.spec.ts b/tests/e2e/dashboard-hydration.spec.ts index 5dfafc3..3d49067 100644 --- a/tests/e2e/dashboard-hydration.spec.ts +++ b/tests/e2e/dashboard-hydration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { getBaseUrl, resetTestData, @@ -8,16 +8,14 @@ import { assertExactDialog, assertUnchangedPair, proposeBoundaryChange, + readPeriod, + readPeriodList, seedBoundaryPair, target, } from "./period-boundary-confirmation-helpers"; +import { seedPeriod } from "./helpers/db"; -test("waits for hydration before editing server-rendered settings", async ({ - page, - request, -}) => { - await resetTestData(request); - await seedBoundaryPair(request); +async function delayClientEntry(page: Page) { const clientRequested = Promise.withResolvers(); const releaseClient = Promise.withResolvers(); const clientEntry = /\/_app\/immutable\/entry\/start\.[^/]+\.js$/; @@ -26,6 +24,24 @@ test("waits for hydration before editing server-rendered settings", async ({ await releaseClient.promise; await route.continue(); }); + return { + clientRequested, + releaseClient, + dispose: async () => { + releaseClient.resolve(); + await page.unroute(clientEntry); + }, + }; +} + +test("waits for hydration before editing server-rendered settings", async ({ + page, + request, +}) => { + await resetTestData(request); + await seedBoundaryPair(request); + const { clientRequested, releaseClient, dispose } = + await delayClientEntry(page); try { await page.goto(`${getBaseUrl()}/?periodId=${target.periodId}`, { @@ -46,7 +62,141 @@ test("waits for hydration before editing server-rendered settings", async ({ await expect(page.getByLabel("期間予算 (円)")).toHaveValue("123456"); await assertUnchangedPair(request); } finally { + await dispose(); + } +}); + +for (const navigation of ["navigation", "reload"] as const) { + test(`waits for hydration after ${navigation} before validating and submitting an initial period`, async ({ + page, + request, + }) => { + await resetTestData(request); + if (navigation === "reload") { + await page.goto(getBaseUrl()); + await waitForDashboardReady(page); + } + const { clientRequested, releaseClient, dispose } = + await delayClientEntry(page); + const writes: string[] = []; + page.on("request", (request) => { + if (["POST", "PUT"].includes(request.method())) + writes.push(request.url()); + }); + + try { + if (navigation === "reload") { + await page.reload({ waitUntil: "domcontentloaded" }); + } else { + await page.goto(getBaseUrl(), { waitUntil: "domcontentloaded" }); + } + await clientRequested.promise; + const start = page.getByTestId("initial-period-range-start"); + const id = page.getByLabel("期間ID", { exact: true }); + await expect(start).toBeVisible(); + await expect(start).toBeEnabled(); + await expect(page.getByRole("main")).toHaveAttribute("aria-busy", "true"); + + const ready = waitForDashboardReady(page); + releaseClient.resolve(); + await ready; + await start.fill("2026-09-"); + await page.getByTestId("initial-period-range-end").fill("2026-09-30"); + await page.getByLabel("新規予算額 (円)").fill("130000"); + await id.fill("p-hydrated-initial"); + await page + .getByRole("button", { name: "期間を作成", exact: true }) + .click(); + await expect(start).toBeFocused(); + await expect(start).toHaveValue("2026-09-"); + await expect(start).toHaveAttribute("aria-invalid", "true"); + await expect(start).toHaveAccessibleDescription(/有効な開始日/); + expect(writes).toEqual([]); + expect(await readPeriodList(request)).toEqual([]); + + await start.fill("2026-09-01"); + await expect(start).toHaveAttribute("aria-invalid", "false"); + await id.press("Enter"); + await expect(page.getByTestId("period-select")).toHaveValue( + "p-hydrated-initial", + ); + await expect(page.locator("#selected-period-heading")).toBeFocused(); + expect(writes).toEqual([`${getBaseUrl()}/api/periods`]); + expect(await readPeriod(request, "p-hydrated-initial")).toMatchObject({ + startDate: "2026-09-01", + endDate: "2026-09-30", + budgetYen: 130000, + }); + } finally { + await dispose(); + } + }); +} + +test("waits for hydration before calendar keys and additional period creation", async ({ + page, + request, +}) => { + await resetTestData(request); + await seedPeriod(request, getBaseUrl(), { + periodId: "p-hydrated-calendar", + startDate: "2026-01-20", + endDate: "2026-02-10", + budgetYen: 120000, + }); + const { clientRequested, releaseClient, dispose } = + await delayClientEntry(page); + + try { + await page.goto(`${getBaseUrl()}/?periodId=p-hydrated-calendar`, { + waitUntil: "domcontentloaded", + }); + await clientRequested.promise; + const januaryEnd = page.getByTestId("calendar-day-2026-01-31"); + const februaryStart = page.getByTestId("calendar-day-2026-02-01"); + const additional = page.getByTestId("create-period-panel"); + await expect(januaryEnd).toBeVisible(); + await expect(januaryEnd).toBeEnabled(); + await expect(additional.locator("summary")).toBeVisible(); + await expect(page.getByRole("main")).toHaveAttribute("aria-busy", "true"); + + const ready = waitForDashboardReady(page); releaseClient.resolve(); - await page.unroute(clientEntry); + await ready; + await januaryEnd.focus(); + await januaryEnd.press("ArrowRight"); + await expect(februaryStart).toBeFocused(); + await expect( + page.locator('[data-testid^="calendar-day-"][tabindex="0"]'), + ).toHaveCount(1); + await februaryStart.press("Enter"); + const modal = page.getByTestId("day-entry-modal"); + await expect(modal).toContainText("対象日: 2026-02-01"); + await modal.getByRole("button", { name: "閉じる" }).click(); + await expect(modal).not.toBeVisible(); + await expect(februaryStart).toBeFocused(); + await expect(februaryStart).toHaveAttribute("aria-pressed", "true"); + + await additional.locator("summary").click(); + await additional + .getByTestId("create-period-range-start") + .fill("2026-02-11"); + await additional.getByTestId("create-period-range-end").fill("2026-03-10"); + await additional.getByLabel("新規予算額 (円)").fill("90000"); + await additional + .getByLabel("期間ID", { exact: true }) + .fill("p-hydrated-next"); + await additional.getByLabel("期間ID", { exact: true }).press("Enter"); + await expect(page.getByTestId("period-select")).toHaveValue( + "p-hydrated-next", + ); + await expect(page.locator("#selected-period-heading")).toBeFocused(); + expect(await readPeriod(request, "p-hydrated-next")).toMatchObject({ + startDate: "2026-02-11", + endDate: "2026-03-10", + budgetYen: 90000, + }); + } finally { + await dispose(); } }); diff --git a/tests/e2e/dashboard-integrated-flow.spec.ts b/tests/e2e/dashboard-integrated-flow.spec.ts index 39fdca5..b0dcd67 100644 --- a/tests/e2e/dashboard-integrated-flow.spec.ts +++ b/tests/e2e/dashboard-integrated-flow.spec.ts @@ -3,7 +3,12 @@ import { configureDashboardDayEntryE2E, saveDayEntrySuccessfully, } from "./dashboard-day-entry-helpers"; -import { addDays, getBaseUrl, getCurrentJstDate } from "./dashboard-shared"; +import { + addDays, + getBaseUrl, + getCurrentJstDate, + waitForDashboardReady, +} from "./dashboard-shared"; configureDashboardDayEntryE2E(); @@ -31,6 +36,7 @@ for (const viewport of [ page.on("pageerror", (error) => errors.push(error.message)); await page.goto(`${getBaseUrl()}/`); + await waitForDashboardReady(page); await expect(page.getByTestId("create-period-panel")).toBeVisible(); await page.getByLabel("期間ID", { exact: true }).fill(periodId); await page.getByTestId("initial-period-range-start").fill(start); @@ -180,6 +186,7 @@ for (const viewport of [ await expect(dialog).toHaveCount(0); await expect(currentRange).toHaveText(`${start} - ${today}`); await page.reload(); + await waitForDashboardReady(page); await expect(currentRange).toHaveText(`${start} - ${today}`); await expect(page.getByTestId("budget-value")).toContainText("130,000"); await select.selectOption(nextId); @@ -187,6 +194,7 @@ for (const viewport of [ `${addDays(today, 1)} - ${extendedEnd}`, ); await page.reload(); + await waitForDashboardReady(page); await expect(select).toHaveValue(periodId); await select.selectOption(nextId); await expect(currentRange).toHaveText( diff --git a/tests/e2e/dashboard-period-operation-state.spec.ts b/tests/e2e/dashboard-period-operation-state.spec.ts index 74ff63f..9d3f1b9 100644 --- a/tests/e2e/dashboard-period-operation-state.spec.ts +++ b/tests/e2e/dashboard-period-operation-state.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { getBaseUrl, resetTestData } from "./dashboard-shared"; +import { + getBaseUrl, + resetTestData, + waitForDashboardReady, +} from "./dashboard-shared"; import { seedPeriod } from "./helpers/db"; import { applyRange, @@ -34,6 +38,7 @@ test.describe("independent period operations", () => { test.beforeEach(async ({ page, request }) => { await seedPeriod(request, getBaseUrl(), period); await page.goto(`${getBaseUrl()}/?periodId=${period.periodId}`); + await waitForDashboardReady(page); await openSettings(page); }); diff --git a/tests/e2e/dashboard-range-settings.spec.ts b/tests/e2e/dashboard-range-settings.spec.ts index 37f907f..38addbd 100644 --- a/tests/e2e/dashboard-range-settings.spec.ts +++ b/tests/e2e/dashboard-range-settings.spec.ts @@ -5,7 +5,12 @@ import { type APIRequestContext, } from "@playwright/test"; import { seedPeriod } from "./helpers/db"; -import { addDays, getBaseUrl, resetTestData } from "./dashboard-shared"; +import { + addDays, + getBaseUrl, + resetTestData, + waitForDashboardReady, +} from "./dashboard-shared"; import { holdResponse, waitForResponse, @@ -30,6 +35,7 @@ async function openRange(page: Page, request: APIRequestContext) { budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=${periodId}`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const form = page.getByRole("form", { name: "期間設定", exact: true }); return { diff --git a/tests/e2e/dashboard-shell.spec.ts b/tests/e2e/dashboard-shell.spec.ts index 6bceb75..0e0d95c 100644 --- a/tests/e2e/dashboard-shell.spec.ts +++ b/tests/e2e/dashboard-shell.spec.ts @@ -5,6 +5,7 @@ import { getBaseUrl, getCurrentJstDate, resetTestData, + waitForDashboardReady, } from "./dashboard-shared"; test.beforeEach(async ({ request }) => { @@ -61,6 +62,7 @@ test("shows shell regions in document order", async ({ page, request }) => { test("keeps empty and additional create bodies reachable", async ({ page }) => { await page.goto(getBaseUrl()); + await waitForDashboardReady(page); await expect(page.locator("#empty-period-heading")).toBeVisible(); await expect(page.getByLabel("期間ID")).toBeVisible(); await page.getByLabel("期間ID").fill("p-shell-created"); @@ -89,6 +91,7 @@ test("keeps the selected summary while a failed selection is reported at the she budgetYen: 90000, }); await page.goto(`${getBaseUrl()}/?periodId=p-shell-current`); + await waitForDashboardReady(page); const retryBarrier = Promise.withResolvers(); let futureGetCount = 0; const futureUrl = `${getBaseUrl()}/api/periods/p-shell-future`; @@ -175,6 +178,7 @@ test("keeps the current draft while an exact period GET is loading", async ({ budgetYen: 90000, }); await page.goto(`${getBaseUrl()}/?periodId=p-shell-current`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); await page.getByLabel("期間予算 (円)").fill("130000"); @@ -221,6 +225,7 @@ test("keeps a failed additional create error visible while settings stay closed" budgetYen: 120000, }); await page.goto(`${getBaseUrl()}/?periodId=p-shell-current`); + await waitForDashboardReady(page); const settings = page .getByText("期間の終了日や予算を変更する") diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 78f2f9b..d04b20e 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -5,6 +5,7 @@ import { getBaseUrl, getCurrentJstDate, resetTestData, + waitForDashboardReady, } from "./dashboard-shared"; test.describe.configure({ mode: "serial", timeout: 120_000 }); @@ -18,6 +19,7 @@ test("creates period", async ({ page }) => { const endDate = addDays(today, 29); await page.goto(`${getBaseUrl()}/`); + await waitForDashboardReady(page); await expect(page.getByTestId("create-period-panel")).toBeVisible(); await expect(page.getByLabel("期間ID")).toBeVisible(); await expect(page.getByLabel("新規予算額 (円)")).toBeVisible(); @@ -61,6 +63,7 @@ test("preserves manually edited create ID after applying range", async ({ page.on("pageerror", (error) => pageErrors.push(error.message)); await page.goto(`${getBaseUrl()}/`); + await waitForDashboardReady(page); await page.getByLabel("期間ID").fill(periodId); await page.getByTestId("initial-period-range-start").fill(today); await page.getByTestId("initial-period-range-end").fill(endDate); @@ -108,6 +111,7 @@ test("updates period start and end dates from settings inputs", async ({ }); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await expect(page.getByTestId("period-id")).toContainText(periodId); await page.getByText("期間の終了日や予算を変更する").click(); await page.getByTestId("current-period-range-start").fill(updatedStartDate); @@ -149,6 +153,7 @@ test("switches between current and future budget periods", async ({ }); await page.goto(`${getBaseUrl()}/`); + await waitForDashboardReady(page); await expect(page.getByTestId("period-id")).toContainText(currentPeriodId); await page.getByTestId("period-select").selectOption(futurePeriodId); @@ -200,6 +205,7 @@ test("creates the next budget period from secondary settings", async ({ }); await page.goto(`${getBaseUrl()}/`); + await waitForDashboardReady(page); await expect(page.getByTestId("period-id")).toContainText(currentPeriodId); await page.getByText("次の予算期間を作成する").click(); await page.getByLabel("期間ID").fill(nextPeriodId); @@ -235,6 +241,7 @@ test("shows an error when shrinking a period would exclude saved entries", async }); await page.goto(`${getBaseUrl()}/?periodId=${encodeURIComponent(periodId)}`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); await page.getByTestId("current-period-range-end").fill(invalidEndDate); await page.getByTestId("current-period-range-apply").click(); diff --git a/tests/e2e/framework-error-retry.spec.ts b/tests/e2e/framework-error-retry.spec.ts index 4978fcd..e227d0c 100644 --- a/tests/e2e/framework-error-retry.spec.ts +++ b/tests/e2e/framework-error-retry.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from "@playwright/test"; -import { getBaseUrl, resetTestData } from "./dashboard-shared"; +import { + getBaseUrl, + resetTestData, + waitForDashboardReady, +} from "./dashboard-shared"; test("retries a framework page-load error without losing URL or focus", async ({ page, @@ -9,6 +13,7 @@ test("retries a framework page-load error without losing URL or focus", async ({ const initialUrl = `${getBaseUrl()}/?periodId=before-retry`; const retryUrl = `${getBaseUrl()}/?periodId=after-retry&keep=1#empty-period-heading`; await page.goto(initialUrl); + await waitForDashboardReady(page); await expect(page.locator("#empty-period-heading")).toBeVisible(); const releaseRetry = Promise.withResolvers(); diff --git a/tests/e2e/period-range-picker.spec.ts b/tests/e2e/period-range-picker.spec.ts index 8d9a792..eb806d8 100644 --- a/tests/e2e/period-range-picker.spec.ts +++ b/tests/e2e/period-range-picker.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; import { seedPeriod } from "./helpers/db"; -import { getBaseUrl, resetTestData } from "./dashboard-shared"; +import { + getBaseUrl, + resetTestData, + waitForDashboardReady, +} from "./dashboard-shared"; test.describe.configure({ mode: "serial", timeout: 120_000 }); @@ -20,6 +24,7 @@ test("raw input retains invalid text, associates errors, and makes no mutation", }); await page.goto(`${getBaseUrl()}/?periodId=p-range-360`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); let mutationCount = 0; @@ -104,6 +109,7 @@ test("calendar sync publishes a partial selection without retaining the old end" }); await page.goto(`${getBaseUrl()}/?periodId=p-range-calendar-sync`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const calendar = page.locator("[data-range-calendar-root]").first(); @@ -155,6 +161,7 @@ test("calendar starts a fresh range from invalid and reversed controlled raw val }); await page.goto(`${getBaseUrl()}/?periodId=p-range-calendar-raw`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const start = page.getByTestId("current-period-range-start"); @@ -204,6 +211,7 @@ test("calendar synchronizes a parent period reset without retaining its old rang }); await page.goto(`${getBaseUrl()}/?periodId=p-range-calendar-reset-current`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const calendar = page.locator("[data-range-calendar-root]").first(); @@ -241,6 +249,7 @@ test("calendar keyboard follows the manual inputs and updates the controlled ran await page.setViewportSize({ width: 1280, height: 800 }); await page.goto(`${getBaseUrl()}/?periodId=p-range-calendar-keyboard`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const start = page.getByTestId("current-period-range-start"); @@ -295,6 +304,7 @@ test("calendar disables its mouse and keyboard controls while a range save is he }); await page.goto(`${getBaseUrl()}/?periodId=${periodId}`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const periodUrl = `${getBaseUrl()}/api/periods/${periodId}`; @@ -360,6 +370,7 @@ test("calendar keeps day controls usable on a mobile viewport", async ({ await page.setViewportSize({ width: 375, height: 812 }); await page.goto(`${getBaseUrl()}/?periodId=p-range-calendar-mobile`); + await waitForDashboardReady(page); await page.getByText("期間の終了日や予算を変更する").click(); const day = page @@ -386,6 +397,7 @@ test("create apply preserves raw drafts until valid and reaches both create POST request, }) => { await page.goto(getBaseUrl()); + await waitForDashboardReady(page); const initialId = page.getByLabel("期間ID"); const initialStart = page.getByTestId("initial-period-range-start"); await page.getByTestId("initial-period-range-apply").click(); @@ -427,6 +439,7 @@ test("create apply preserves raw drafts until valid and reaches both create POST budgetYen: 120000, }); await page.goto(getBaseUrl()); + await waitForDashboardReady(page); await page.getByText("次の予算期間を作成する").click(); const additionalId = page.getByLabel("期間ID"); await additionalId.fill("custom-additional-id");