diff --git a/apps/booskiff-web/bff/auth-real.test.ts b/apps/booskiff-web/bff/auth-real.test.ts index cf14ab3..6b699fd 100644 --- a/apps/booskiff-web/bff/auth-real.test.ts +++ b/apps/booskiff-web/bff/auth-real.test.ts @@ -60,7 +60,7 @@ test("returns OAuth next URL and Kratos cookies without an app session when logi // Then: OAuth 開始 URL と Kratos cookie のみを返す。 expect(response?.status).toBe(200); expect(await response?.json()).toEqual({ - authenticated: true, username: "alice@example.test", next: "/auth/oauth/start?return_to=/login", + authenticated: true, username: "alice@example.test", next: "/auth/oauth/start?return_to=/drive", }); expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("ory_kratos_session="))).toBe(true); expect(response?.headers.getSetCookie().some((cookie) => cookie.startsWith("booskiff_session="))).toBe(false); diff --git a/apps/booskiff-web/bff/auth-real.ts b/apps/booskiff-web/bff/auth-real.ts index b6bd808..bd2f57c 100644 --- a/apps/booskiff-web/bff/auth-real.ts +++ b/apps/booskiff-web/bff/auth-real.ts @@ -112,8 +112,9 @@ export async function realLogin(req: Request, config: RealAuthConfig, adapter: S const headers = new Headers({ "Content-Type": "application/json" }); jar.applyToResponse(headers); // XHR は 302 で cross-origin Hydra chain を進められないため、トップレベル遷移用 URL を渡す。 - // return_to=/login は #19 の SPA 経路回避策。修正後に /drive へ戻すことを再検討する。 - return new Response(JSON.stringify({ authenticated: true, username: email, next: "/auth/oauth/start?return_to=/login" }), { status: 200, headers }); + // return_to=/drive: callback 後の /drive フルロードで発生していた resume DOM 破損 (#19) は + // #21 で修正済みのため、SPA 経路回避策 (return_to=/login) は解除する。 + return new Response(JSON.stringify({ authenticated: true, username: email, next: "/auth/oauth/start?return_to=/drive" }), { status: 200, headers }); } catch (err) { console.error("Kratos login error:", err); return Response.json({ error: "Authentication service unavailable" }, { status: 502 }); diff --git a/apps/booskiff-web/e2e/real/tests/auth.spec.ts b/apps/booskiff-web/e2e/real/tests/auth.spec.ts index 1b5f5a1..b8f1a9f 100644 --- a/apps/booskiff-web/e2e/real/tests/auth.spec.ts +++ b/apps/booskiff-web/e2e/real/tests/auth.spec.ts @@ -16,10 +16,8 @@ async function enterCredentials(page: Page): Promise { }).toPass({ timeout: 20_000 }); // Real-mode login success hands off to /auth/oauth/start automatically (the // BFF `next` URL), so the Hydra consent screen arrives through the UI flow - // alone. return_to=/login is applied BFF-side: a direct full load of /drive - // after the OAuth callback corrupts the resumed DOM (issue #19), so landing - // on /login and letting the session check navigate client-side follows the - // healthy SPA path. + // alone. return_to=/drive: the OAuth callback lands on a full /drive load, + // whose resumed DOM stays intact since the issue #19 fix (#21). await expect(page.getByRole("button", { name: "Allow", exact: true })).toBeVisible({ timeout: 30_000 }); } @@ -89,12 +87,12 @@ test("real login grants a cookie session, drive access, and logout", async ({ pa const created = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/folders" && response.request().method() === "POST"); await page.getByTestId("folder-create-submit").click(); expect((await created).ok()).toBe(true); - // Persistence is verified through the API: a full /drive reload re-triggers the - // resumed-DOM corruption noted above, so the browser-side reload assertion is - // intentionally left to the follow-up fix. - const folders = await page.request.get("/api/folders"); - expect(folders.ok()).toBe(true); - expect(JSON.stringify(await folders.json())).toContain(folder); + // Persistence is verified through the UI after a full /drive reload: SSR + + // resumeMount must render the stored folder without the DOM corruption that + // issue #19 documented (fixed in #21). + await page.goto("/drive"); + await expect(page.getByTestId("drive-page")).toBeVisible(); + await expect(page.getByTestId("folder-list")).toContainText(folder); await page.getByTestId("logout-button").click(); await expect(page).toHaveURL(/\/login$/); diff --git a/apps/booskiff-web/e2e/tests/drive-resume.spec.ts b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts new file mode 100644 index 0000000..c4b9273 --- /dev/null +++ b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { folderRow, loginViaUi, runId } from "./helpers"; + +// Regression for #19 / #21: an authenticated FULL page load of /drive (SSR + +// resumeMount, then CheckSession -> LoadDrive -> *Loaded patches) must not +// corrupt the client DOM. The observed corruption was duplicated folder +// section elements (folder-create-submit / folder-list rendered twice), +// children leaking between sections, and dead event handlers. +test("authenticated /drive full reload keeps a single, working folder section", async ({ + page, +}) => { + await loginViaUi(page); + + // Full page load: SSR HTML + resumeMount + session/drive data patches. + // Register response waits BEFORE the navigation so no patch is missed. + const filesLoaded = page.waitForResponse( + (response) => + response.url().includes("/api/files") && + response.request().method() === "GET", + ); + const foldersLoaded = page.waitForResponse( + (response) => + response.url().includes("/api/folders") && + response.request().method() === "GET", + ); + const billingLoaded = page.waitForResponse( + (response) => response.url().includes("/api/billing/status"), + ); + await page.goto("/drive"); + await filesLoaded; + await foldersLoaded; + await billingLoaded; + await expect(page.getByTestId("drive-page")).toBeVisible(); + + // Folder section elements must exist exactly once (no duplication). + await expect(page.getByTestId("folder-create-submit")).toHaveCount(1); + await expect(page.getByTestId("folder-list")).toHaveCount(1); + await expect(page.getByTestId("folder-name-input")).toHaveCount(1); + + // Event handlers must survive resume: folder creation still works. + const name = `resume-${runId}`; + await page.getByTestId("folder-name-input").fill(name); + await page.getByTestId("folder-create-submit").click(); + await expect(folderRow(page, name)).toBeVisible({ timeout: 15_000 }); +}); diff --git a/apps/booskiff-web/src/App/View/Drive.purs b/apps/booskiff-web/src/App/View/Drive.purs index a3fb103..4d18e23 100644 --- a/apps/booskiff-web/src/App/View/Drive.purs +++ b/apps/booskiff-web/src/App/View/Drive.purs @@ -33,9 +33,17 @@ view model = , fileSection model ] +-- | Invisible stand-in for "render nothing" slots. An empty text node never +-- | survives HTML parsing, but Flame's hydration matches SSR DOM to virtual +-- | children purely by index — a vanished placeholder shifts every following +-- | sibling onto the wrong node and later patches then corrupt the DOM +-- | (duplicated sections, leaked children, dead handlers; #19/#21). +emptySlot :: Html Message +emptySlot = HE.div [ HA.style { display: "none" } ] [] + errorBanner :: Maybe String -> Html Message errorBanner = case _ of - Nothing -> HE.text "" + Nothing -> emptySlot Just msg -> HE.div [ HA.class' @@ -96,8 +104,8 @@ uploadSection model = ] [ HE.text "アップロード" ] ] - , maybe (HE.text "") progressView model.upload - , maybe (HE.text "") uploadErrorView (uploadErrorFor model) + , maybe emptySlot progressView model.upload + , maybe emptySlot uploadErrorView (uploadErrorFor model) ] uploadErrorFor :: Model -> Maybe String @@ -166,7 +174,7 @@ folderSection model = allButton :: Maybe String -> Html Message allButton = case _ of - Nothing -> HE.text "" + Nothing -> emptySlot Just _ -> HE.button [ HA.class' ("text-sm " <> T.navLink)