From adc9389121a8f47355838b673a1c8695732b78d5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:02:15 +0800 Subject: [PATCH 1/2] test(ui): stabilize hydrated browser interactions --- tests/answer-progress-ui-smoke.spec.ts | 49 ++++++++++++++++++++------ tests/ui-smoke.spec.ts | 13 +++++-- tests/ui-stress.spec.ts | 37 +++++++++++++++---- tests/ui-tools.spec.ts | 22 +++++++----- tests/ui-universal-search.spec.ts | 32 +++++++++++++++-- 5 files changed, 122 insertions(+), 31 deletions(-) diff --git a/tests/answer-progress-ui-smoke.spec.ts b/tests/answer-progress-ui-smoke.spec.ts index 903316562..1b74ed6a0 100644 --- a/tests/answer-progress-ui-smoke.spec.ts +++ b/tests/answer-progress-ui-smoke.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "playwright/test"; import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; const readySetupChecks = [ @@ -9,6 +9,39 @@ const readySetupChecks = [ { id: "openai", label: "Answer provider", status: "ready", detail: "Mock stream ready." }, ]; +async function waitForReactEventHandler(locator: Locator, eventName: "onChange" | "onSubmit") { + await expect + .poll( + async () => + locator.evaluate((element, reactEventName) => { + const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$")); + if (!propsKey) return false; + const props = (element as unknown as Record>)[propsKey]; + return typeof props?.[reactEventName] === "function"; + }, eventName), + { timeout: 15_000 }, + ) + .toBe(true); +} + +async function fillHydratedAnswerQuestion(page: Page, value: string) { + const input = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible'); + const submit = page.locator('[aria-label="Generate source-backed answer"]:visible'); + + await expect(async () => { + await expect(input).toHaveCount(1, { timeout: 30_000 }); + await expect(submit).toHaveCount(1, { timeout: 30_000 }); + const form = input.locator("xpath=ancestor::form[1]"); + await waitForReactEventHandler(input, "onChange"); + await waitForReactEventHandler(form, "onSubmit"); + await input.fill(value); + await expect(input).toHaveValue(value); + await expect(submit).toBeEnabled(); + }).toPass({ timeout: 30_000 }); + + return submit; +} + async function mockDashboardApis(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()); @@ -195,10 +228,7 @@ test("answer progress remains user-safe through fallback and keeps a compact com await installTimedAnswerStream(page); await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" }); - const input = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); - const submit = page.locator('[aria-label="Generate source-backed answer"]:visible').first(); - await expect(input).toBeEditable({ timeout: 30_000 }); - await input.fill("Lithium dosing"); + const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing"); await submit.click(); const progress = page.getByTestId("answer-progress-stepper"); @@ -234,17 +264,14 @@ test("a completion frame cannot mark a previous answer complete when final is in await installSuccessfulThenInvalidAnswerStreams(page); await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" }); - const input = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible').first(); - const submit = page.locator('[aria-label="Generate source-backed answer"]:visible').first(); - await expect(input).toBeEditable({ timeout: 30_000 }); - await input.fill("Lithium dosing"); + const submit = await fillHydratedAnswerQuestion(page, "Lithium dosing"); await submit.click(); await expect(page.getByText(/In the synthetic lithium document/i)).toBeVisible({ timeout: 8_000 }); await expect(page.getByTestId("answer-progress-stepper")).toHaveAttribute("data-progress-state", "complete"); - await input.fill("What about monitoring?"); - await submit.click(); + const followUpSubmit = await fillHydratedAnswerQuestion(page, "What about monitoring?"); + await followUpSubmit.click(); await expect(page.getByTestId("answer-error")).toContainText("Answer stream returned an invalid final payload", { timeout: 10_000, diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7aa5aa1fb..67ded7f9d 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -135,10 +135,17 @@ async function isVisibleWithoutThrow(locator: Locator) { } async function fillVisibleQuestionInput(page: Page, value: string) { - const questionInput = visibleQuestionInput(page); - const submitAnswer = visibleAnswerSubmitButton(page); + const questionInput = page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible'); + const submitAnswer = page.locator('[aria-label="Generate source-backed answer"]:visible'); await expect(async () => { + // A production navigation can briefly overlap or replace the server-rendered + // composer. Require one settled React owner before filling so the new client + // tree cannot discard the value and leave submit disabled. + await expect(questionInput).toHaveCount(1, { timeout: uiAssertionTimeoutMs }); + await expect(submitAnswer).toHaveCount(1, { timeout: uiAssertionTimeoutMs }); + await waitForReactEventHandler(questionInput, "onChange"); + await waitForReactEventHandler(questionInput.locator("xpath=ancestor::form[1]"), "onSubmit"); await expect(submitAnswer).toHaveAttribute("title", /Enter a clinical question|Generate a source-backed answer/, { timeout: uiAssertionTimeoutMs, }); @@ -146,7 +153,7 @@ async function fillVisibleQuestionInput(page: Page, value: string) { await questionInput.fill(value); await expect(questionInput).toHaveValue(value, { timeout: uiAssertionTimeoutMs }); await expect(submitAnswer).toBeEnabled({ timeout: uiAssertionTimeoutMs }); - }).toPass({ timeout: 15_000 }); + }).toPass({ timeout: uiAssertionTimeoutMs }); return questionInput; } diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index d6ce3e1b6..2be0daa05 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -1,5 +1,5 @@ import type { Route } from "playwright-core"; -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "playwright/test"; import { stubZeroTouchPoints } from "./helpers/zero-touch"; import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; import { readPrimaryScrollGeometry } from "./playwright-scroll"; @@ -7,6 +7,21 @@ import { readPrimaryScrollGeometry } from "./playwright-scroll"; const longTitle = "Extremely long synthetic shared-care guideline title covering lithium clozapine perinatal risk ADHD medication review emergency escalation and outpatient monitoring pathways"; +async function waitForReactEventHandler(locator: Locator, eventName: "onChange" | "onSubmit") { + await expect + .poll( + async () => + locator.evaluate((element, reactEventName) => { + const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$")); + if (!propsKey) return false; + const props = (element as unknown as Record>)[propsKey]; + return typeof props?.[reactEventName] === "function"; + }, eventName), + { timeout: 15_000 }, + ) + .toBe(true); +} + function makeDocument(index: number) { return { id: `10000000-0000-4000-8000-${String(index).padStart(12, "0")}`, @@ -356,11 +371,21 @@ test.describe("Clinical KB long-content stress coverage", () => { await page.goto("/?mode=answer", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("button", { name: "Mode Answer" })).toBeVisible(); - await page - .locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible') - .first() - .fill("Show all stress citations and source cards"); - await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click(); + // Production hydration can briefly replace the server-rendered composer. + // Require one settled owner with live React handlers before typing, or the + // replacement can lose the value and leave the submit button disabled. + const answerSurface = page.getByTestId("answer-empty-state"); + await expect(answerSurface).toHaveCount(1, { timeout: 15_000 }); + const questionInput = answerSurface.locator('[aria-label^="Search indexed guidelines by question or keyword"]'); + await expect(questionInput).toHaveCount(1); + const answerForm = questionInput.locator("xpath=ancestor::form[1]"); + await waitForReactEventHandler(questionInput, "onChange"); + await waitForReactEventHandler(answerForm, "onSubmit"); + await questionInput.fill("Show all stress citations and source cards"); + await expect(questionInput).toHaveValue("Show all stress citations and source cards"); + const submit = answerForm.getByRole("button", { name: "Generate source-backed answer" }); + await expect(submit).toBeEnabled({ timeout: 15_000 }); + await submit.click(); await expect(page.getByLabel("Source-backed answer")).toBeVisible(); await expect(page.getByTestId("plain-answer-response")).toBeVisible(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 0dd0f5b2c..3f5e2bf09 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1772,8 +1772,12 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials"); - const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); + const input = page.locator('input[placeholder="Ask or search a presentation"]:visible'); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); + await expect(input).toHaveCount(1, { timeout: 15_000 }); + await expect(submit).toHaveCount(1, { timeout: 15_000 }); + await waitForReactEventHandler(input, "onChange"); + await waitForReactEventHandler(input.locator("xpath=ancestor::form[1]"), "onSubmit"); await input.fill("acute confusion"); await expect(submit).toBeEnabled(); const searchResponse = page.waitForResponse( @@ -1797,13 +1801,15 @@ test.describe("Clinical KB tools launcher", () => { await input.focus(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); - await scrollPrimarySurface(page, "end"); - await expect - .poll(async () => { - const geometry = await readPrimaryScrollGeometry(page); - return geometry.maxScrollTop - geometry.scrollTop; - }) - .toBeLessThanOrEqual(1); + // The visible dock/reserve can finish its layout commit after the first + // endpoint scroll, increasing document height. Treat scrolling to the live + // endpoint and measuring it as one retriable action; a persistent clearance + // regression still fails this assertion. + await expect(async () => { + await scrollPrimarySurface(page, "end"); + const geometry = await readPrimaryScrollGeometry(page); + expect(geometry.maxScrollTop - geometry.scrollTop).toBeLessThanOrEqual(1); + }).toPass({ timeout: 15_000 }); expect((await readPrimaryScrollGeometry(page)).owner).toBe("document"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThan(180); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 7bb7e7d71..2bee931d4 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page, type Route } from "playwright/test"; +import { expect, test, type Locator, type Page, type Route } from "playwright/test"; import { stubZeroTouchPoints } from "./helpers/zero-touch"; // Cross-entity universal typeahead in the command surface. The universal endpoint is @@ -86,7 +86,9 @@ async function fulfillUniversalSearch(route: Route, response: typeof universalPa async function mockUniversalSearch(page: Page) { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { - const mode = new URL(route.request().url()).searchParams.get("mode") ?? "documents"; + const requestUrl = new URL(route.request().url()); + const mode = requestUrl.searchParams.get("mode") ?? "documents"; + const query = requestUrl.searchParams.get("q") ?? ""; const preferredByMode: Record = { answer: ["documents"], documents: ["documents"], @@ -102,6 +104,7 @@ async function mockUniversalSearch(page: Page) { const responseOrder = universalPayload.groups.map((group) => group.kind); await fulfillUniversalSearch(route, { ...universalPayload, + query, contextMode: mode, preferredDomains, domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))], @@ -109,10 +112,33 @@ async function mockUniversalSearch(page: Page) { }); } +async function waitForReactChangeHandler(locator: Locator) { + await expect + .poll( + async () => + locator.evaluate((element) => { + const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$")); + if (!propsKey) return false; + const props = (element as unknown as Record>)[propsKey]; + return typeof props?.onChange === "function"; + }), + { timeout: 15_000 }, + ) + .toBe(true); +} + async function openComposer(page: Page, href = "/?mode=documents&focus=1") { await page.goto(href, { waitUntil: "domcontentloaded" }); - const input = page.getByTestId("global-search-input").first(); + // Do not hide a transient server/client overlap with `.first()`. Wait for one + // settled composer and its React handler so a hydration replacement cannot + // discard the subsequent fill while the full browser suite is under load. + const input = page.getByTestId("global-search-input"); + await expect(input).toHaveCount(1, { timeout: 15_000 }); + await expect(input).toBeVisible(); + await expect(input).toBeEnabled(); + await waitForReactChangeHandler(input); await input.click(); + await expect(input).toBeFocused(); return input; } From 1816500cef4c112c96335aa81e1937bfe1235b50 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:36:01 +0800 Subject: [PATCH 2/2] docs: record settings follow-up review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index d4d22abe2..506952ab7 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1124,3 +1124,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-27 | branch-cleanup exact merged-head batch | `multi-head` | branch-cleanup | REMOVED. Deleted 30 additional clean worktrees and 34 additional local branches whose exact tips matched GitHub merged PR head records. Preserved the newly merged `codex/settings-ux` worktree under a recent-work grace rule, along with all checked-out, dirty, open-PR, closed-unmerged, secret-safeguarded, process-locked, or unmatched refs. | GitHub merged/open PR head inventory; exact OID match immediately before removal; no force worktree removal; no application tests or provider-backed application workflows run. | | 2026-07-27 | branch-cleanup aged and closed-ref batch | `multi-head` | branch-cleanup | REMOVED. Deleted 36 clean branch-backed worktrees older than 72 hours while retaining their refs, then deleted 16 old temporary or exact closed-PR local refs and 11 exact closed-PR remote refs after verified recovery bundles. Restored and retained one permission-locked Antigravity worktree; retained all dirty, open-PR, recent, archive/preserve, secret-bearing, high-risk, divergent, detached, or potentially useful UI refs. | Fresh GitHub open/all/closed PR inventories; 72-hour creation/closure cutoff; exact tip checks; four verified incremental bundles for 21 refs in this pass; no force worktree removal, application tests, or provider-backed application workflows run. | | 2026-07-27 | `codex/publish-document-nav-20260727` (PR #1278) | `71d442e7921c36fe036128207c9925484a908fd0` | Protected-main review of preserved cleanup records, reusable review prompts, and document navigation mockups | APPROVE pending final exact-head hosted required checks. The unique preserved work was transplanted onto current `origin/main`; stale phone-chrome and unsafe 15-minute lock-expiry patches were excluded. The first hosted static run found arbitrary mockup font sizes, which were replaced with the established named type-scale tokens. Review found no remaining P0-P3 issue and no retrieval, clinical-output, provider, or production-route behavior change. | Flight plan, Prettier, docs index/scripts/links, sitemap, branch-ledger, type-scale, icon-scale, brand, design-system, and `git diff --check` PASS; hosted build, static, unit coverage, advisory mockup UI, safety, policy, Semgrep, and secret checks PASS on reviewed head; Production UI pending at ledger append; local heavy gates deferred behind legitimate shared exclusive owners; no non-GitHub provider-backed checks. | +| 2026-07-27 | `codex/settings-followup` | `806fcc4c3167d9e2f9fbd832c39e53d3491f270a` | Protected-main release-readiness review of settings follow-up browser reliability | APPROVE. The test-only diff waits for one settled React owner before strict answer/search interactions, makes universal-search mocks echo the requested query, and retries scroll-to-live-endpoint geometry after late dock layout. Review found no P0-P3 issue and no product, retrieval, ranking, clinical-output, or provider behavior change. Highest residual risk is physical iOS/WebKit behavior outside local Chromium coverage. | Focused integrated production Chromium PASS (5/5); exact integrated-head `verify:pr-local` PASS (runtime, formatting, lint, typecheck, 393 files, 3,538 passed / 2 skipped, 36 offline RAG fixtures); `verify:ui` PASS (323/323); `git diff --check` PASS; no non-GitHub provider-backed checks. |