/** 本番static web buildとloopback HTTP fixtureでUI境界だけを検証する。 * 最新のweb/workspace/buildが前提で、このsuiteはbuildしない。 * 製品・Worker・modelは起動せず、Journal保存・認可の保証はBackend試験が担う。 * tools/web-uxのtest:browserまたはこのfileを明示選択して実行する。 */ import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join } from "@std/path"; import { chromium, type Locator, type Page } from "playwright"; import { journalFixture as fixture } from "./journal_browser_fixture.ts"; import { assertMainOwnsVerticalScroll, assertNoPageWideOverflow, freePort, mainScrollState, waitForServer, } from "./memory_document_harness.ts"; const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); const artifacts = join(root, "target/web-ux/t-737-browser-interactions"); const subjectPath = `/w/${fixture.workspaceId}/memory/${fixture.subjectId}`; const journalPath = `${subjectPath}/journal`; const memoryPath = `${subjectPath}/${fixture.memoryId}?change_id=change-current`; const claim = "Keep subject Memory provenance typed and visible"; const entryTitle = "Release milestone 00 · release [a+b] %_"; const detailPath = `${journalPath}?journal=journal-release&entry=entry-00`; async function ordinaryAnchor(link: Locator) { assertEquals(await link.evaluate((el) => el.tagName), "A"); assertEquals(await link.getAttribute("target"), null); assert(!(await link.textContent())?.includes("↗")); assert((await link.getAttribute("href"))?.startsWith("/w/")); } async function restoreTop(page: Page, expected: number) { await page.waitForFunction( (top) => { const main = (globalThis as any).document.querySelector("main"); return main && Math.abs(main.scrollTop - top) <= 3; }, expected, { timeout: 10_000 }, ); assert(Math.abs((await mainScrollState(page)).scrollTop - expected) <= 3); } async function capture(page: Page, name: string) { await assertNoPageWideOverflow(page); await assertMainOwnsVerticalScroll(page); await page.screenshot({ path: join(artifacts, `${name}.png`) }); } Deno.test("Journal fixture UI keeps scoped navigation, bounded links and distinct read states", async (t) => { await Deno.stat(join(root, "web/workspace/build/index.html")); await Deno.mkdir(artifacts, { recursive: true }); const port = await freePort(); const base = `http://127.0.0.1:${port}`; const server = new Deno.Command(Deno.execPath(), { args: [ "run", "--allow-net", "--allow-read", join( root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts", ), String(port), join(root, "web/workspace/build"), ], stdout: "null", stderr: "null", }).spawn(); try { await waitForServer(`${base}/health`); const browser = await chromium.launch({ headless: true }); const requests: { path: string; method: string; payload: Record | null; }[] = []; const errors: string[] = []; try { const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, }); context.on("request", (request) => { const path = new URL(request.url()).pathname; if (path.startsWith("/api/")) { requests.push({ path, method: request.method(), payload: request.method() === "POST" ? request.postDataJSON() : null, }); } }); context.on( "page", (page) => page.on("pageerror", (e) => errors.push(String(e))), ); const page = await context.newPage(); await t.step( "Subject entry and multiple Sessions retain pending/source state and continuation", async () => { await page.goto(`${base}${subjectPath}`); const entry = page.getByRole("link", { name: "Journal · 最近の出来事とSessionを探す →", exact: true, }); await entry.waitFor(); await ordinaryAnchor(entry); await entry.click(); await page.locator("[data-journal-view='list']").waitFor(); await page.getByRole("link", { name: "SessionごとのJournal", exact: true, }).click(); await page.getByRole("link", { name: "Release coordination Session →", exact: true, }) .waitFor(); assertEquals(await page.locator(".session-list > li").count(), 2); await page.getByText("記録待ち・未反映の原資料があります。", { exact: true, }).waitFor(); await capture(page, "sessions-desktop-light"); const next = page.getByRole("link", { name: "次のページ →", exact: true, }); await next.click(); await page.getByRole("link", { name: "Archived planning Session →", exact: true, }) .waitFor(); assertEquals( new URL(page.url()).searchParams.get("view"), "sessions", ); assertEquals( new URL(page.url()).searchParams.get("after"), "fixture-sessions-2", ); await page.getByRole("link", { name: "一覧の先頭へ", exact: true }) .click(); await page.getByRole("link", { name: "Release coordination Session →", exact: true, }) .click(); await page.getByRole("heading", { name: "Current Session position" }) .waitFor(); await page.getByRole("heading", { name: "出来事の経緯", exact: true }) .waitFor(); assert((await page.locator(".entry-list > li").count()) > 1); assertEquals( new URL(page.url()).searchParams.get("journal"), "journal-release", ); }, ); await t.step( "literal search and bounded continuation preserve the actual URL query", async () => { await page.goto(`${base}${journalPath}`); await page.getByRole("searchbox", { name: "出来事を検索" }).fill( fixture.literalQuery, ); await page.getByRole("button", { name: "検索", exact: true }).click(); await page.waitForURL((url) => url.searchParams.get("q") === fixture.literalQuery ); await page.getByRole("link", { name: new RegExp(entryTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), }).waitFor(); assertEquals(await page.locator(".entry-list > li").count(), 16); await page.getByRole("link", { name: "次のページ →", exact: true }) .click(); await page.waitForURL((url) => url.searchParams.get("after") === "fixture-entries-2" ); assertEquals( new URL(page.url()).searchParams.get("q"), fixture.literalQuery, ); assertEquals( await page.getByRole("searchbox").inputValue(), fixture.literalQuery, ); await page.getByText("Release milestone 16 · release [a+b] %_", { exact: true, }) .waitFor(); assertEquals(await page.locator(".entry-list > li").count(), 12); const searchRequests = requests.filter((r) => r.payload?.action === "entries" && r.payload.query === fixture.literalQuery ); assert( searchRequests.some((r) => r.payload?.after === "fixture-entries-2" ), ); }, ); for ( const viewport of [ { width: 1440, height: 900, theme: "light" as const }, { width: 768, height: 900, theme: "dark" as const }, { width: 390, height: 844, theme: "light" as const }, ] ) { await t.step( `list → entry → Memory → Back → Back restores main scroll at ${viewport.width}`, async () => { await page.setViewportSize(viewport); await page.emulateMedia({ colorScheme: viewport.theme }); await page.goto( `${base}${journalPath}?q=${ encodeURIComponent(fixture.literalQuery) }`, ); const target = page.locator(".entry-list a").filter({ hasText: "Release milestone 12", }); await target.waitFor(); await target.scrollIntoViewIfNeeded(); const listTop = (await mainScrollState(page)).scrollTop; assert( listTop > 100, "list must actually be scrolled before testing restoration", ); const listUrl = page.url(); await ordinaryAnchor(target); await target.click(); await page.getByRole("heading", { name: "Release milestone 12 · release [a+b] %_", exact: true, }).waitFor(); const memoryLink = page.locator(".related-list").getByRole("link", { name: `${claim} →`, exact: true, }); await memoryLink.scrollIntoViewIfNeeded(); const detailTop = (await mainScrollState(page)).scrollTop; assert( detailTop > 100, "detail must actually be scrolled before testing restoration", ); const detailUrl = page.url(); await capture( page, `detail-before-memory-${viewport.width}-${viewport.theme}`, ); await ordinaryAnchor(memoryLink); assertEquals(await memoryLink.getAttribute("href"), memoryPath); await memoryLink.click(); await page.getByRole("heading", { name: claim, exact: true }) .waitFor(); assertEquals( new URL(page.url()).pathname, `${subjectPath}/${fixture.memoryId}`, ); assertEquals( new URL(page.url()).searchParams.get("change_id"), "change-current", ); const journalLinks = page.locator( "#related-journal .journal-link-list a", ); assertEquals(await journalLinks.count(), 3); const journalLink = journalLinks.filter({ hasText: entryTitle }); await ordinaryAnchor(journalLink); assertEquals(await journalLink.getAttribute("href"), detailPath); assert( (await journalLink.textContent())!.length > entryTitle.length + 4, "Journal link includes date as well as title", ); await journalLink.scrollIntoViewIfNeeded(); await capture( page, `memory-related-${viewport.width}-${viewport.theme}`, ); await page.goBack(); await page.locator("[data-journal-view='detail']").waitFor(); assertEquals(page.url(), detailUrl); await restoreTop(page, detailTop); await page.goBack(); await page.getByRole("searchbox").waitFor(); assertEquals(page.url(), listUrl); assertEquals( await page.getByRole("searchbox").inputValue(), fixture.literalQuery, ); await restoreTop(page, listTop); await capture( page, `list-back-${viewport.width}-${viewport.theme}`, ); }, ); } await t.step( "related previews, immutable candidates, normal same-tab and new-tab links", async () => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`${base}${detailPath}`); await page.getByRole("heading", { name: entryTitle, exact: true }) .waitFor(); assertEquals(await page.locator(".related-list a").count(), 3); await page.getByRole("link", { name: "すべて見る(関連Memoryの続き) →", exact: true, }) .click(); await page.getByText("Related release lesson 3 →", { exact: true }) .waitFor(); assertEquals(await page.locator(".related-list a").count(), 5); assertEquals(new URL(page.url()).searchParams.get("related"), "all"); assert(new URL(page.url()).searchParams.has("related_after")); await page.locator(".related-list").scrollIntoViewIfNeeded(); await capture(page, "journal-all-related-1440-light"); await page.getByRole("link", { name: "関連Memoryの先頭へ", exact: true, }).click(); const memoryLink = page.locator(".related-list a").filter({ hasText: claim, }); const popupReady = context.waitForEvent("page"); await memoryLink.click({ button: "middle" }); const popup = await popupReady; await popup.getByRole("heading", { name: claim, exact: true }) .waitFor(); assertEquals( new URL(popup.url()).searchParams.get("change_id"), "change-current", ); const backLink = popup.locator("#related-journal a").filter({ hasText: entryTitle, }); const journalPopupReady = context.waitForEvent("page"); await backLink.click({ button: "middle" }); const journalPopup = await journalPopupReady; await journalPopup.getByRole("heading", { name: entryTitle, exact: true, }).waitFor(); await journalPopup.getByRole("heading", { name: "Preserved Journal narrative", exact: true, }).waitFor(); await journalPopup.close(); await popup.getByRole("link", { name: "すべて見る(関連Journalの続き) →", exact: true, }) .click(); await popup.locator("#related-journal a").filter({ hasText: "Release milestone 03", }) .waitFor(); assertEquals( await popup.locator("#related-journal .journal-link-list a") .count(), 5, ); assertEquals( new URL(popup.url()).searchParams.get("journal_source_offset"), "3", ); await popup.locator("#related-journal").scrollIntoViewIfNeeded(); await capture(popup, "memory-all-related-1440-light"); await popup.locator("#related-journal a").filter({ hasText: "Release milestone 03", }) .click(); await popup.getByRole("heading", { name: "Release milestone 03 · release [a+b] %_", exact: true, }).waitFor(); assertEquals( new URL(popup.url()).searchParams.get("entry"), "entry-03", ); await popup.getByText("関連するMemoryは未接続です。", { exact: true }) .waitFor(); assertEquals(await popup.locator(".related-list a").count(), 0); await popup.close(); const candidateRequests = requests.filter((r) => r.payload?.action === "memory_sources" ); assert(candidateRequests.length > 0); for (const r of candidateRequests) { assertEquals(r.payload?.selector.candidate_id, fixture.candidateId); assertEquals(r.payload?.selector.change_id, "change-current"); assertEquals(r.payload?.selector.memory_id, fixture.memoryId); } }, ); await t.step( "bounded narrative continuation carries its content token and recovers from stale text", async () => { await page.goto(`${base}${detailPath}`); const body = page.getByRole("region", { name: "出来事の本文", exact: true, }); await body.getByRole("link", { name: "次の本文区間 →", exact: true }) .click(); await page.getByRole("heading", { name: "Journal narrative complete", exact: true, }) .waitFor(); assertEquals( new URL(page.url()).searchParams.get("body_token"), "content-entry-0", ); assert(Number(new URL(page.url()).searchParams.get("body_byte")) > 0); await body.getByRole("link", { name: "本文の先頭へ", exact: true }) .click(); await page.getByRole("heading", { name: "Preserved Journal narrative", exact: true, }) .waitFor(); await page.goto( `${base}${detailPath}&body_byte=8192&body_token=stale-content-token`, ); await body.getByRole("alert").waitFor(); await body.getByRole("link", { name: "先頭から読み直す", exact: true, }).click(); await page.getByRole("heading", { name: "Preserved Journal narrative", exact: true, }) .waitFor(); }, ); await t.step( "correction targets and correcting-entry links resolve directly", async () => { await page.goto(`${base}${detailPath}`); const correction = page.getByRole("link").filter({ hasText: "Corrected release evidence", }); await correction.waitFor(); await ordinaryAnchor(correction); await correction.click(); await page.getByRole("heading", { name: "Corrected release evidence", exact: true, }) .waitFor(); const target = page.getByRole("link", { name: `${entryTitle} →`, exact: true, }); await ordinaryAnchor(target); assertEquals(await target.getAttribute("href"), detailPath); await target.click(); await page.getByRole("heading", { name: entryTitle, exact: true }) .waitFor(); await page.getByRole("region", { name: "継続中の経緯", exact: true }) .waitFor(); }, ); await t.step( "missing and unavailable original sources do not prevent saved Journal reading", async () => { for ( const state of [ { journal: "journal-release", entry: "entry-00", text: "元の会話は削除済み", }, { journal: "journal-review", entry: "entry-01", text: "原資料は取得不能(存在・閲覧可否を確認できません)", }, ] ) { await page.goto( `${base}${journalPath}?journal=${state.journal}&entry=${state.entry}`, ); await page.getByRole("heading", { name: "Preserved Journal narrative", exact: true, }) .waitFor(); await page.getByRole("region", { name: "原資料", exact: true }) .getByText(state.text, { exact: true, }).waitFor(); assertEquals( await page.getByRole("link", { name: "元の会話を開く →", exact: true, }).count(), 0, ); assertEquals(await page.locator(".related-list a").count(), 3); } await page.goto( `${base}${journalPath}?journal=journal-planning&entry=entry-04`, ); await page.getByText("原資料の本文は未確認です。", { exact: true }) .waitFor(); const originalLink = page.getByRole("link", { name: "元の会話を確認する →", exact: true, }); await ordinaryAnchor(originalLink); assertEquals( await originalLink.getAttribute("href"), "/w/memory-review/workers/W-fixture/console", ); await originalLink.scrollIntoViewIfNeeded(); await capture(page, "retained-original-navigation-1440-light"); }, ); await t.step( "empty, pending, unconnected, no search result and request failures remain distinct", async () => { for ( const state of [ { path: `/w/${fixture.workspaceId}/memory/empty-subject/journal?view=sessions`, text: "Journalはまだありません。", role: "status" as const, }, { path: `${journalPath}?q=definitely-no-such-literal`, text: "検索結果は0件です。", role: "status" as const, }, { path: `${journalPath}?q=fixture-failure`, text: "出来事一覧を取得できません。", role: "alert" as const, }, { path: `${journalPath}?journal=journal-denied&entry=entry-00`, text: "Journalエントリを取得できません", role: "alert" as const, }, ] ) { await page.goto(`${base}${state.path}`); await page.getByRole(state.role).filter({ hasText: state.text }) .waitFor(); assertEquals( await page.locator(".entry-list a, .related-list a").count(), 0, ); await assertNoPageWideOverflow(page); } await page.goto( `${base}${subjectPath}/memory-resolved-0002?change_id=change-earlier`, ); await page.locator("#related-journal").getByText( "対応するJournalは未接続", { exact: true, }, ).waitFor(); assertEquals(await page.locator("#related-journal a").count(), 0); assert( !(await page.locator("#related-journal").textContent())?.includes( "記録待ち", ), ); assert(requests.some((r) => r.payload?.action === "memory_sources" && r.payload.selector.memory_id === "memory-resolved-0002" && r.payload.selector.change_id === "change-earlier" && r.payload.selector.candidate_id === fixture.candidateId )); await page.goto( `${base}${subjectPath}/memory-sources-failure?change_id=change-current`, ); await page.getByRole("alert").filter({ hasText: "Journal参照を取得できません。", }) .waitFor(); await page.getByRole("article", { name: "Committed Memory body", exact: true, }).waitFor(); assertEquals( await page.locator("#related-journal .journal-link-list a").count(), 0, ); }, ); assertEquals(errors, []); assertEquals( requests.filter((r) => r.method !== "GET" && !(r.method === "POST" && r.path.endsWith("/journal-browser")) ), [], ); assertEquals( requests.filter((r) => /history|restore|start/.test(r.path)), [], ); for ( const request of requests.filter((r) => r.path.endsWith("/journal-browser") ) ) { assertEquals(request.method, "POST"); if (request.payload?.limit != null) { assert(request.payload.limit > 0 && request.payload.limit <= 20); } if (request.payload?.action === "text") { assert( request.payload.max_bytes > 0 && request.payload.max_bytes <= 8192, ); } } await Deno.writeTextFile( join(artifacts, "requests.json"), JSON.stringify(requests, null, 2), ); await context.close(); } finally { await browser.close(); } } finally { server.kill("SIGTERM"); await server.status; } });