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"
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"] }
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..1cd601892
--- /dev/null
+++ b/ui/tests/room-info-key-selection.spec.ts
@@ -0,0 +1,372 @@
+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 });
+}
+
+/**
+ * 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) =>
+ 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",
+ 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("/");
+ 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(`${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.
+ //
+ // 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, { 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 }) => {
+ 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);
+ });
+
+ 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);
+ });
+});