From 995cbeb9b50f36d0e7923ca53f175a8351516e4a Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Wed, 29 Jul 2026 09:16:55 -0500 Subject: [PATCH 1/5] fix(ui): restore Firefox text selection for room key fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Room Public Key, Contract ID and Secret Version fields in the room-details panel carried Tailwind's `select-all` (`user-select: all`). Firefox parses that rule — the computed value really is `all` — but selecting inside an under it yields a ZERO-length selection, so click-drag and double-click both did nothing and Ctrl+C copied nothing. Chromium and WebKit select the whole value under the same rule, which is why the bug presented as Firefox-specific. Declare `user-select: text` explicitly instead of relying on the `auto` default, and add a copy button beside the two long values. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QaMA3cqXTBiTNmPYcMeZsi --- .../components/room_list/edit_room_modal.rs | 100 ++++++- ui/tests/room-info-key-selection.spec.ts | 249 ++++++++++++++++++ 2 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 ui/tests/room-info-key-selection.spec.ts diff --git a/ui/src/components/room_list/edit_room_modal.rs b/ui/src/components/room_list/edit_room_modal.rs index 3d6fe6d68..9a4556ae3 100644 --- a/ui/src/components/room_list/edit_room_modal.rs +++ b/ui/src/components/room_list/edit_room_modal.rs @@ -4,6 +4,8 @@ use crate::components::app::{CURRENT_ROOM, EDIT_ROOM_MODAL, ROOMS}; use crate::util::ecies::{seal_for_room, unseal_bytes_with_secrets}; use dioxus::logger::tracing::{error, info, warn}; use dioxus::prelude::*; +use dioxus_free_icons::icons::fa_solid_icons::FaCopy; +use dioxus_free_icons::Icon; use freenet_scaffold::ComposableState; use river_core::room_state::configuration::{AuthorizedConfigurationV1, Configuration}; use river_core::room_state::privacy::{PrivacyMode, RoomDisplayMetadata}; @@ -150,12 +152,24 @@ pub fn EditRoomModal() -> Element { title: "Ed25519 public key (Curve25519 elliptic curve)", "Room Public Key" } - input { - r#type: "text", - readonly: true, - title: "Ed25519 public key (Curve25519 elliptic curve)", - class: "w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", - value: "{bs58::encode(room_data.owner_vk.as_bytes()).into_string()}" + div { + class: "flex items-center gap-2", + input { + r#type: "text", + readonly: true, + "data-testid": "room-public-key-input", + title: "Ed25519 public key (Curve25519 elliptic curve)", + // `select-text`, NOT `select-all` — see the note on + // `CopyButton` below. `user-select: all` makes this + // field completely unselectable in Firefox. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", + value: "{bs58::encode(room_data.owner_vk.as_bytes()).into_string()}" + } + CopyButton { + value: bs58::encode(room_data.owner_vk.as_bytes()).into_string(), + testid: "room-public-key-copy-button", + label: "Copy room public key", + } } } // Contract ID @@ -165,11 +179,21 @@ pub fn EditRoomModal() -> Element { class: "block text-sm font-medium text-text-muted mb-1", "Contract ID" } - input { - r#type: "text", - readonly: true, - class: "w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", - value: "{room_data.contract_key.id()}" + div { + class: "flex items-center gap-2", + input { + r#type: "text", + readonly: true, + "data-testid": "contract-id-input", + // `select-text`, NOT `select-all` — see `CopyButton`. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", + value: "{room_data.contract_key.id()}" + } + CopyButton { + value: room_data.contract_key.id().to_string(), + testid: "contract-id-copy-button", + label: "Copy contract ID", + } } } @@ -193,7 +217,9 @@ pub fn EditRoomModal() -> Element { input { r#type: "text", readonly: true, - class: "flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", + "data-testid": "secret-version-input", + // `select-text`, NOT `select-all` — see `CopyButton`. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", value: "{secret_version}" } if is_owner { @@ -358,6 +384,56 @@ pub fn EditRoomModal() -> Element { } } +/// Copy-to-clipboard button for the read-only key fields above. +/// +/// # Why those inputs are `select-text` and must never be `select-all` +/// +/// The Room Public Key / Contract ID / Secret Version inputs used Tailwind's +/// `select-all` (`user-select: all`), which made them impossible to select or +/// copy by hand in Firefox: click-drag selected nothing, double-click selected +/// nothing (freenet/river#537). Firefox parses the declaration — the computed +/// value really is `all` — but selecting inside an `` under it yields a +/// zero-length selection, so `Ctrl+C` copies nothing. Chromium and WebKit +/// instead select the whole value, which is why this looked Firefox-specific. +/// +/// Measured with a standalone repro driven by Playwright (characters selected +/// by a click-drag across the field, then by a double-click): +/// +/// | input rule | Firefox | Chromium | WebKit | +/// |---------------------------|---------|----------|--------| +/// | `user-select: all` | **0** | 44 | 44 | +/// | `user-select: text` | 44 | 44 | 44 | +/// +/// So the fields declare `select-text` explicitly rather than relying on the +/// `auto` default. Do NOT "restore" `select-all` — it re-breaks Firefox. +/// Pinned by `ui/tests/room-info-key-selection.spec.ts`, which runs against +/// Firefox in CI. +/// +/// This is a child component rather than inline markup because the "Copied!" +/// feedback needs `use_signal`, and the fields render inside an `if let` +/// branch where a hook call would be conditional. +#[component] +fn CopyButton(value: String, testid: String, label: String) -> Element { + let mut copied = use_signal(|| false); + let value_for_clipboard = value.clone(); + + rsx! { + button { + r#type: "button", + "data-testid": "{testid}", + "aria-label": "{label}", + title: "{label}", + class: "flex-shrink-0 px-3 py-2 bg-surface hover:bg-surface-hover border border-border rounded-lg text-text-muted hover:text-text text-sm transition-colors flex items-center gap-1.5", + onclick: move |_| { + crate::util::copy_to_clipboard(&value_for_clipboard); + copied.set(true); + }, + Icon { icon: FaCopy, width: 12, height: 12 } + span { if *copied.read() { "Copied!" } else { "Copy" } } + } + } +} + #[component] fn RoomDescriptionField(config: Configuration, is_owner: bool) -> Element { let initial_desc = { diff --git a/ui/tests/room-info-key-selection.spec.ts b/ui/tests/room-info-key-selection.spec.ts new file mode 100644 index 000000000..5dc718088 --- /dev/null +++ b/ui/tests/room-info-key-selection.spec.ts @@ -0,0 +1,249 @@ +import { test, expect, Page, Locator } from "@playwright/test"; + +// Regression test for freenet/river#537: the Room Public Key and Contract ID +// in the room-details panel could not be selected or copied in Firefox. +// +// The fields carried Tailwind's `select-all` (`user-select: all`). Firefox +// parses that — the computed value really is `all` — but selecting inside an +// `` under it produces a ZERO-length selection, so click-drag and +// double-click both did nothing and Ctrl+C copied nothing. Chromium and WebKit +// select the whole value under the same rule, which is why the bug looked +// Firefox-specific. The fix declares `user-select: text` explicitly. +// +// This spec runs on every project in playwright.config.ts, so the Firefox +// project is the actual gate; chromium/webkit assert the fix did not regress +// the browsers that already worked. + +const ROOM_NAME = "Public Discussion Room"; + +// Playwright's WebKit build will not deliver a keyboard copy to a READONLY +// input. Isolated with a standalone page containing nothing but two inputs, +// one `readonly` and one editable — no River code involved: +// +// engine | readonly Ctrl+A | readonly mouse-select | readonly Ctrl+C | editable +// firefox | selects | selects | copy fires | all work +// chromium | selects | selects | copy fires | all work +// webkit | selects NOTHING | selects | NO copy event | all work +// +// So on WebKit only the KEYBOARD half is unobservable; mouse selection — the +// behaviour this spec exists to protect — works and is asserted on every +// project including webkit and mobile-safari. These fields were already +// `readonly` before the fix, so this is not a regression, and it is a property +// of the harness rather than of the app. Firefox is the browser from the bug +// report, so the Ctrl+C acceptance criterion is still genuinely gated. +const WEBKIT_KEYBOARD_COPY_SKIP = + "Playwright's WebKit does not deliver a keyboard copy to a readonly input (harness limitation, verified against a bare input outside River); mouse selection is still asserted on webkit."; + +async function waitForApp(page: Page) { + await page.waitForSelector(".app-root", { timeout: 30_000 }); + await expect(page.locator("aside, .app-root button")).not.toHaveCount(0); +} + +async function openRoomDetails(page: Page) { + const vp = page.viewportSize(); + if (vp && vp.width < 1024) { + await page.setViewportSize({ width: 1280, height: vp.height }); + } + + // Scope to the room list — once a room is selected the header button carries + // the room name as its accessible name too, which would be a second match. + const roomBtn = page.getByTestId("room-list").getByRole("button", { name: ROOM_NAME }); + await expect(roomBtn).toBeVisible({ timeout: 10_000 }); + await roomBtn.click(); + await expect(page.getByRole("heading", { name: ROOM_NAME })).toBeVisible({ timeout: 5_000 }); + + // The (i) affordance in the room header opens the room-details modal. + await page.getByTitle("Room details").click(); + await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 5_000 }); +} + +/** Text the browser would put on the clipboard for `Ctrl+C` on this input. */ +function selectedText(input: Locator) { + return input.evaluate((el: HTMLInputElement) => + el.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0) + ); +} + +async function clearSelection(input: Locator) { + await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(0, 0)); +} + +async function dragAcross(page: Page, input: Locator) { + const box = await input.boundingBox(); + if (!box) throw new Error("input has no bounding box"); + const y = box.y + box.height / 2; + await page.mouse.move(box.x + 8, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 12, y, { steps: 12 }); + await page.mouse.up(); +} + +for (const field of [ + { testid: "room-public-key-input", label: "room public key" }, + { testid: "contract-id-input", label: "contract ID" }, +]) { + test.describe(`room-details ${field.label} is selectable`, () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test(`click-drag selects the ${field.label}`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + await expect(input).toBeVisible(); + const value = await input.inputValue(); + expect(value.length).toBeGreaterThan(0); + + await dragAcross(page, input); + + // Before the fix this was "" on Firefox. + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + // The drag starts/ends a few px inside the field, so on a value wider + // than the box it is a partial selection; either way it must be a real, + // contiguous run of the value. + expect(value).toContain(selected); + }); + + test(`double-click selects the ${field.label}`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + await clearSelection(input); + await input.dblclick(); + + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + expect(value).toContain(selected); + }); + + test(`Ctrl+C copies a mouse selection of the ${field.label}`, async ({ page, browserName }) => { + test.skip(browserName === "webkit", WEBKIT_KEYBOARD_COPY_SKIP); + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + // Record what the browser actually hands to the clipboard. Reading the + // system clipboard needs per-browser permissions that Firefox does not + // grant in Playwright, so observe the copy event instead: its default + // payload is the field's current selection. + await input.evaluate((el: HTMLInputElement) => { + (window as unknown as { __copied?: string }).__copied = undefined; + el.addEventListener("copy", () => { + (window as unknown as { __copied?: string }).__copied = el.value.substring( + el.selectionStart ?? 0, + el.selectionEnd ?? 0 + ); + }); + }); + + // Drive the selection with the MOUSE, which is what the bug broke. + // Ctrl+A is deliberately not used here: keyboard select-all still works + // under `user-select: all`, so a Ctrl+A-driven copy passes even on the + // broken build and would make this test non-discriminating. + await clearSelection(input); + await input.dblclick(); + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + + await page.keyboard.press("ControlOrMeta+c"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied), { + timeout: 2_000, + }) + .toBe(selected); + expect(value).toContain(selected); + }); + + test(`Ctrl+A then Ctrl+C copies the whole ${field.label}`, async ({ page, browserName }) => { + test.skip(browserName === "webkit", WEBKIT_KEYBOARD_COPY_SKIP); + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + await input.evaluate((el: HTMLInputElement) => { + (window as unknown as { __copied?: string }).__copied = undefined; + el.addEventListener("copy", () => { + (window as unknown as { __copied?: string }).__copied = el.value.substring( + el.selectionStart ?? 0, + el.selectionEnd ?? 0 + ); + }); + }); + + await input.click(); + await page.keyboard.press("ControlOrMeta+a"); + expect(await selectedText(input)).toBe(value); + + await page.keyboard.press("ControlOrMeta+c"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied), { + timeout: 2_000, + }) + .toBe(value); + }); + + test(`selecting the ${field.label} does not close the modal`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + await dragAcross(page, input); + await input.dblclick(); + + // Acceptance criterion: no surrounding click/modal/drag behaviour regresses. + await expect(page.getByTestId("edit-room-modal")).toBeVisible(); + }); + }); +} + +test.describe("room-details copy buttons", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const field of [ + { input: "room-public-key-input", button: "room-public-key-copy-button" }, + { input: "contract-id-input", button: "contract-id-copy-button" }, + ]) { + test(`${field.button} confirms the copy`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const value = await page.getByTestId(field.input).inputValue(); + expect(value.length).toBeGreaterThan(0); + + const button = page.getByTestId(field.button); + await expect(button).toBeVisible(); + await expect(button).toHaveText(/Copy/); + + await button.click(); + await expect(button).toHaveText(/Copied!/, { timeout: 2_000 }); + }); + } + + test("the room-details panel does not overflow horizontally", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + // The copy buttons put the inputs in a flex row; `flex-1 min-w-0` must keep + // a long base58 value from widening the modal. + const modal = page.getByTestId("edit-room-modal"); + const overflow = await modal.evaluate( + (el) => el.scrollWidth - el.clientWidth + ); + expect(overflow).toBeLessThanOrEqual(1); + }); +}); From e7058e258dad5aa0a1030e9abf153897938dd68d Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Wed, 29 Jul 2026 09:38:42 -0500 Subject: [PATCH 2/5] build: make the UI build tasks generate Tailwind CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ui/assets/styles.css` is gitignored (it is a build product), but only `build-ui` and `dev-example` depended on `build-tailwind`. The other four dx-driven tasks — `build-ui-example`, `build-ui-no-sync`, `build-ui-example-no-sync` and `dev` — did not, so on a clean checkout they fail with: error: Asset at /assets/styles.css doesn't exist `build-ui-example-no-sync` is the task AGENTS.md tells you to run before the Playwright suite, so this bites anyone following the documented workflow. CI is unaffected either way because build.yml runs `npm run build:css` as its own explicit step, which is why this went unnoticed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QaMA3cqXTBiTNmPYcMeZsi --- Makefile.toml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Makefile.toml b/Makefile.toml index dfa6bad30..180a4aeb2 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -250,7 +250,11 @@ target/native/x86_64-unknown-linux-gnu/${BUILD_PROFILE}/web-container-tool sign description = "Build the Dioxus UI with example data" env = { UI_FEATURES = "example-data" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required because ui/assets/styles.css is gitignored (it is a +# build product). Without it `dx build` fails on a clean tree with +# "Asset at /assets/styles.css doesn't exist" — CI only gets away with omitting +# it here because build.yml runs `npm run build:css` as an explicit step. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -259,7 +263,8 @@ cwd = "./ui" description = "Build the Dioxus UI without Freenet sync" env = { UI_FEATURES = "no-sync" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -268,7 +273,10 @@ cwd = "./ui" description = "Build the Dioxus UI with example data and no Freenet sync" env = { UI_FEATURES = "example-data,no-sync" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. This is the +# task AGENTS.md tells you to run before the Playwright suite, so it has to +# work from a clean checkout. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -787,7 +795,9 @@ dependencies = ["build-ui"] description = "Development build" env = { UI_FEATURES = "" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. (dev-example +# already had it; this task was the odd one out.) +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["serve"] cwd = "./ui" From 7a4d9aaa7ce804eef28a24631009d86fdef52a8f Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Wed, 29 Jul 2026 09:38:50 -0500 Subject: [PATCH 3/5] chore(ui): declare the dioxus version actually in use (0.7.9) The manifest declared `dioxus = "0.7.3"` while Cargo.lock has resolved 0.7.9 for some time. Two concrete consequences: - AGENTS.md documents ui/Cargo.toml as a debugging trap: someone checking a stale `dx` CLI against the declared version would wrongly conclude a 0.7.3 CLI was correct. - build.yml keys its `dx` cache on `hashFiles('ui/Cargo.toml')`, with a comment saying a dioxus bump should invalidate the cached CLI. That only works if the declared version tracks the real one. 0.7.9 is the latest stable dioxus (0.8.0-alpha.0 is a pre-release and is deliberately not adopted here). Cargo.lock is byte-identical before and after, so resolution and every WASM artifact are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QaMA3cqXTBiTNmPYcMeZsi --- ui/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 6080ea703..e514460f1 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -28,7 +28,7 @@ rand.workspace = true getrandom = { version = "0.2.15", features = ["js", "wasm-bindgen", "js-sys"], default-features = false } # UI Framework -dioxus = { version = "0.7.3", features = ["web"] } +dioxus = { version = "0.7.9", features = ["web"] } # Only `fa_solid_icons` are referenced in the UI (verified by grep). Dropping # the brands/regular feature sets trims their icon modules from the build. dioxus-free-icons = { version = "0.10.0", features = ["font-awesome-solid"] } From f58f0f9e460c031d3b61a15bb2801a9bf7365181 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Wed, 29 Jul 2026 09:51:51 -0500 Subject: [PATCH 4/5] test(ui): assert copy buttons copy the right value, reset, and fit at 320px Review pass on the new spec found three gaps: - The copy-button tests only asserted the label flips to "Copied!", which is a side effect that is identical whether or not the two buttons are wired to the right values. Capture what the app actually writes to the clipboard (by patching document.execCommand, since copy_to_clipboard deliberately uses execCommand so it works in the sandboxed iframe) and assert each button copies ITS field. Mutation-tested: swapping the two values in the component makes exactly these two tests fail. - No coverage that the "Copied!" feedback resets when the panel is closed and reopened, even though copy-clipboard-feedback.spec.ts holds the sibling Export Identity button to that same contract. - The overflow check pinned the viewport to 1280px, so the narrow-width risk that adding a button beside each value actually introduces was untested. Add a 320px check (the smallest width responsive-layout.spec.ts covers) asserting both buttons stay visible and neither the panel nor the document overflows. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QaMA3cqXTBiTNmPYcMeZsi --- ui/tests/room-info-key-selection.spec.ts | 114 ++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/ui/tests/room-info-key-selection.spec.ts b/ui/tests/room-info-key-selection.spec.ts index 5dc718088..d703bf69f 100644 --- a/ui/tests/room-info-key-selection.spec.ts +++ b/ui/tests/room-info-key-selection.spec.ts @@ -57,6 +57,43 @@ async function openRoomDetails(page: Page) { await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 5_000 }); } +/** + * Capture what the app actually writes to the clipboard. + * + * `crate::util::copy_to_clipboard` goes through `document.execCommand('copy')` + * on a throwaway off-screen textarea (so it works inside the gateway's + * sandboxed iframe), which is not readable via the Clipboard API. Patching + * `execCommand` lets us assert the COPIED TEXT rather than just the button's + * label — a button wired to the wrong field would still say "Copied!". + */ +async function captureClipboardWrites(page: Page) { + await page.evaluate(() => { + const w = window as unknown as { __clip: string[] }; + w.__clip = []; + const orig = document.execCommand.bind(document); + document.execCommand = function (cmd: string, ...rest: unknown[]) { + if (cmd === "copy") { + const active = document.activeElement as HTMLTextAreaElement | null; + let copied = active && "value" in active ? active.value : ""; + if (!copied) { + // `select()` is not guaranteed to focus, so fall back to the + // helper's signature: an off-screen fixed-position textarea. + const ta = Array.from(document.querySelectorAll("textarea")).find( + (t) => t.style.left === "-9999px" + ); + copied = ta?.value ?? ""; + } + w.__clip.push(copied); + } + return (orig as (c: string, ...r: unknown[]) => boolean)(cmd, ...rest); + } as typeof document.execCommand; + }); +} + +function clipboardWrites(page: Page) { + return page.evaluate(() => (window as unknown as { __clip: string[] }).__clip); +} + /** Text the browser would put on the clipboard for `Ctrl+C` on this input. */ function selectedText(input: Locator) { return input.evaluate((el: HTMLInputElement) => @@ -213,8 +250,16 @@ test.describe("room-details copy buttons", () => { test.use({ viewport: { width: 1280, height: 800 } }); for (const field of [ - { input: "room-public-key-input", button: "room-public-key-copy-button" }, - { input: "contract-id-input", button: "contract-id-copy-button" }, + { + input: "room-public-key-input", + button: "room-public-key-copy-button", + other: "contract-id-input", + }, + { + input: "contract-id-input", + button: "contract-id-copy-button", + other: "room-public-key-input", + }, ]) { test(`${field.button} confirms the copy`, async ({ page }) => { await page.goto("/"); @@ -231,8 +276,46 @@ test.describe("room-details copy buttons", () => { await button.click(); await expect(button).toHaveText(/Copied!/, { timeout: 2_000 }); }); + + test(`${field.button} copies THAT field's value, not another`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const mine = await page.getByTestId(field.input).inputValue(); + const other = await page.getByTestId(field.other).inputValue(); + expect(mine.length).toBeGreaterThan(0); + expect(mine).not.toBe(other); + + await captureClipboardWrites(page); + await page.getByTestId(field.button).click(); + + // Asserting the button says "Copied!" is NOT enough: it would say that + // just the same if the two buttons' values were swapped. + await expect.poll(() => clipboardWrites(page), { timeout: 2_000 }).toEqual([mine]); + }); } + test("the copy feedback resets when the panel is closed and reopened", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const button = page.getByTestId("room-public-key-copy-button"); + await button.click(); + await expect(button).toHaveText(/Copied!/, { timeout: 2_000 }); + + // Same contract the Export Identity copy button holds + // (copy-clipboard-feedback.spec.ts): reopening must not show a stale + // "Copied!" from a previous visit. + await page.getByTestId("edit-room-close-button").click(); + await expect(page.getByTestId("edit-room-modal")).toHaveCount(0); + + await page.getByTitle("Room details").click(); + await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId("room-public-key-copy-button")).toHaveText(/^Copy$/); + }); + test("the room-details panel does not overflow horizontally", async ({ page }) => { await page.goto("/"); await waitForApp(page); @@ -246,4 +329,31 @@ test.describe("room-details copy buttons", () => { ); expect(overflow).toBeLessThanOrEqual(1); }); + + test("the panel still fits, with both copy buttons, at 320px", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + // The narrow viewport is the whole point of this check: adding a button + // beside each value is exactly what could overflow a small screen, and + // `openRoomDetails` widens to desktop, so the other tests never see it. + // 320px is the smallest width responsive-layout.spec.ts covers. + await page.setViewportSize({ width: 320, height: 800 }); + const modal = page.getByTestId("edit-room-modal"); + await expect(modal).toBeVisible(); + + for (const testid of ["room-public-key-copy-button", "contract-id-copy-button"]) { + await expect(page.getByTestId(testid)).toBeVisible(); + } + + const overflow = await modal.evaluate((el) => el.scrollWidth - el.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + + // The modal itself must not be pushed outside the viewport either. + const docOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ); + expect(docOverflow).toBeLessThanOrEqual(1); + }); }); From 94c449776f3d1d9691ffecc2140297e1f96bd6e2 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Wed, 29 Jul 2026 10:04:34 -0500 Subject: [PATCH 5/5] test(ui): de-flake the copy-feedback reset test under parallel load The reset test flaked once on mobile-safari in a full-suite run (it passed on retry). Investigated rather than retried: - Standalone it passes 10/10 on mobile-safari. - An instrumented 25-cycle open/copy/close/reopen loop showed a stale label 0 times in BOTH WebKit and Firefox, so the reset behaviour itself is sound. The sensitivity is actionability, not correctness: the panel is a `fixed inset-0` overlay, so the room-header (i) button only becomes clickable again once it has fully unmounted, and under the full suite's parallel load (five projects each booting a WASM app) that settle can outrun a 5s budget. Wait for the reopen affordance explicitly and give the reopen assertions the same 15s budget other app-level waits in this suite use. Still non-vacuous: making the copy state persist across remount (the exact regression this guards) fails it with `Received string: "Copied!"`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QaMA3cqXTBiTNmPYcMeZsi --- ui/tests/room-info-key-selection.spec.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ui/tests/room-info-key-selection.spec.ts b/ui/tests/room-info-key-selection.spec.ts index d703bf69f..1cd601892 100644 --- a/ui/tests/room-info-key-selection.spec.ts +++ b/ui/tests/room-info-key-selection.spec.ts @@ -308,12 +308,25 @@ test.describe("room-details copy buttons", () => { // Same contract the Export Identity copy button holds // (copy-clipboard-feedback.spec.ts): reopening must not show a stale // "Copied!" from a previous visit. + // + // The waits below are deliberately generous. The panel is a `fixed + // inset-0` overlay, so the room-header (i) button only becomes actionable + // again once it has fully unmounted, and under the full suite's parallel + // load (five projects, each booting a WASM app) that settle can outrun a + // 5s budget on an otherwise-healthy run. This is contention, not a + // correctness question: an instrumented 25-cycle open/copy/close/reopen + // loop showed a stale label 0 times in both WebKit and Firefox. So wait + // for the reopen affordance explicitly instead of racing it. await page.getByTestId("edit-room-close-button").click(); - await expect(page.getByTestId("edit-room-modal")).toHaveCount(0); - - await page.getByTitle("Room details").click(); - await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 5_000 }); - await expect(page.getByTestId("room-public-key-copy-button")).toHaveText(/^Copy$/); + await expect(page.getByTestId("edit-room-modal")).toHaveCount(0, { timeout: 15_000 }); + + const reopen = page.getByTitle("Room details"); + await expect(reopen).toBeVisible({ timeout: 15_000 }); + await reopen.click(); + await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("room-public-key-copy-button")).toHaveText(/^Copy$/, { + timeout: 15_000, + }); }); test("the room-details panel does not overflow horizontally", async ({ page }) => {