diff --git a/.claude/skills/resolving-issues/SKILL.md b/.claude/skills/resolving-issues/SKILL.md index 88aa0df2..605016be 100644 --- a/.claude/skills/resolving-issues/SKILL.md +++ b/.claude/skills/resolving-issues/SKILL.md @@ -119,6 +119,8 @@ Fresh agent per issue, scoped to one issue + master branch ref. Steps: **Sandbox `git reset --hard origin/` interference (known hazard).** Some sandboxed environments run a periodic `git reset --hard origin/` between tool invocations that silently rolls back uncommitted edits — visible as `Edit`/`Write` results vanishing between cargo commands, or as the working tree being clean when you expected staged changes. Detection: run `git status` after a tool call you expected to leave changes; if it's clean and the file content matches origin, the sandbox wiped it. Recovery: apply edits and `git add -A && git commit` in a tight single-shell pipeline (one `bash -c` invocation) before the next tool call lands. If you accumulate commits-as-checkpoints this way, follow the no-wip-commits rule above by squashing at the end via `git reset --soft && git commit -m "" && git push --force-with-lease`. Note the sandbox-interference workaround in the commit body so the human can audit. + **Pre-staged working tree at session start (expected, NOT anomalous).** The opposite of the sandbox-reset case: implementers commonly find their prescribed diff already applied as uncommitted edits in the working tree the moment they start. Observed across multiple runs (~40-50% of dispatches in PR #566's 10-dispatch run, #567's 9-dispatch run). Mechanism is unclear — possibly the harness caches edits between implementer invocations, or prior session's uncommitted work survives in the sandbox. **Action:** when you see a dirty `git status` matching the brief's prescribed diff at session start, verify the content matches the design (read the file, diff against the brief), run the local merge gate, and commit. Do NOT redo the work — that wastes cycles and risks divergence from the staged version. Do NOT panic — this is now an expected pattern, not a bug. Note the pre-staged finding in the commit body briefly ("working tree had the prescribed diff at session start; verified diff and committed") so the human has the audit trail. Distinguish from the sandbox-reset case above: pre-staged means edits *exist that you didn't make yet* (apply gate + commit); sandbox-reset means edits *vanish that you did make* (re-apply in tight pipeline). + 9. **Mid-fix block** (CI red on the local gate that won't resolve, brainstorm reveals deeper structural issue, fix demands cross-cutting refactor): **abort the dispatch.** `git checkout ` + `git reset --hard origin/` to drop any local work. File a follow-up GH issue (caveman body, link original + cite the blocker). Return to coordinator. The follow-up issue is the durable handoff for the next scheduled run. 10. **Already-fixed-upstream path:** if pre-flight investigation (e.g. `cargo audit`, file-state grep, `cargo tree`) shows the issue was resolved by a recently-merged upstream PR, do NOT make a no-op commit. Leave a caveman comment on the original issue naming the upstream PR + the fix location, close the issue (`completed` if the audit's intent now holds — the upstream fix solved it for us; `not_planned` if the audit's premise is moot — e.g. the targeted code was deleted), report back. Coordinator records under `## Already-Fixed` in the master PR — NOT under `Fixes`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bf9c8b8..38220fa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,9 +183,6 @@ jobs: - name: Run cargo audit run: | cargo audit \ - --ignore RUSTSEC-2026-0098 `# rustls-webpki name-constraint (#223)` \ - --ignore RUSTSEC-2026-0099 `# rustls-webpki URI-constraint (#223)` \ - --ignore RUSTSEC-2026-0104 `# rustls-webpki CRL panic (#223)` \ --ignore RUSTSEC-2026-0097 `# rand unsoundness (#246)` \ --ignore RUSTSEC-2025-0141 `# bincode 1.x unmaintained (#247)` \ --ignore RUSTSEC-2024-0436 `# paste unmaintained, via leptos+iroh (#316)` \ diff --git a/crates/client/src/mutations.rs b/crates/client/src/mutations.rs index 7cdf2c8e..3099cb7e 100644 --- a/crates/client/src/mutations.rs +++ b/crates/client/src/mutations.rs @@ -945,7 +945,7 @@ pub(crate) fn derive_client_events(event: &willow_state::Event) -> Vec { out.push(ClientEvent::ProposalCreated { proposal_hash: event.hash.to_string(), - action_description: format!("{action:?}"), + action_description: format!("{action}"), }); } EventKind::Vote { proposal, accept } => { diff --git a/crates/state/src/event.rs b/crates/state/src/event.rs index 653dc9f1..cf168ebe 100644 --- a/crates/state/src/event.rs +++ b/crates/state/src/event.rs @@ -206,6 +206,41 @@ pub enum VoteThreshold { Count(u32), } +impl std::fmt::Display for ProposedAction { + /// Render a structural, human-readable description of the action. + /// + /// Peer ids are rendered via [`EndpointId`]'s own `Display` (64-char + /// hex). UI layers that want richer rendering (e.g. resolving a peer + /// id to a display name) should consume the typed [`ProposedAction`] + /// directly instead of substring-matching on this string. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProposedAction::GrantAdmin { peer_id } => { + write!(f, "Grant admin to {peer_id}") + } + ProposedAction::RevokeAdmin { peer_id } => { + write!(f, "Revoke admin from {peer_id}") + } + ProposedAction::KickMember { peer_id } => { + write!(f, "Kick {peer_id}") + } + ProposedAction::SetVoteThreshold { threshold } => { + write!(f, "Set vote threshold to {threshold}") + } + } + } +} + +impl std::fmt::Display for VoteThreshold { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VoteThreshold::Majority => f.write_str("majority"), + VoteThreshold::Unanimous => f.write_str("unanimous"), + VoteThreshold::Count(n) => write!(f, "{n} admins"), + } + } +} + // ───── EventKind ─────────────────────────────────────────────────────────── /// All possible state mutations — 22 variants. diff --git a/crates/state/src/tests/voting.rs b/crates/state/src/tests/voting.rs index 641de106..84b9f305 100644 --- a/crates/state/src/tests/voting.rs +++ b/crates/state/src/tests/voting.rs @@ -455,3 +455,85 @@ fn no_vote_proposal_does_not_auto_apply_with_two_admins() { "target should have been kicked" ); } + +// ───── Display impls (issue #571) ────────────────────────────────────────── +// +// These tests pin the exact rendered text used by the client's +// `ClientEvent::ProposalCreated { action_description, .. }` payload (see +// `crates/client/src/mutations.rs` — the `Propose` arm formats with `{}`). +// Previously that site used `{:?}`, leaking Rust Debug rendering into UI +// strings (e.g. "KickMember { peer_id: EndpointId(...) }"). The structural +// peer-id rendering uses `EndpointId`'s own `Display` (64-char hex). UI +// layers that want display-name resolution should consume the typed +// `ProposedAction` directly rather than substring-matching this string. + +#[cfg(test)] +mod display_tests { + use crate::event::{ProposedAction, VoteThreshold}; + use willow_identity::EndpointId; + + /// Construct a deterministic peer id from fixed bytes. `[0u8; 32]` is + /// accepted by `EndpointId::from_bytes` (it's the curve identity), so + /// the output is stable across test runs. + fn fixed_peer() -> EndpointId { + EndpointId::from_bytes(&[0u8; 32]).unwrap() + } + + #[test] + fn display_grant_admin() { + let peer = fixed_peer(); + let action = ProposedAction::GrantAdmin { peer_id: peer }; + // Use peer.to_string() to avoid hard-coding iroh's hex encoding. + let expected = format!("Grant admin to {peer}"); + assert_eq!(format!("{action}"), expected); + } + + #[test] + fn display_revoke_admin() { + let peer = fixed_peer(); + let action = ProposedAction::RevokeAdmin { peer_id: peer }; + let expected = format!("Revoke admin from {peer}"); + assert_eq!(format!("{action}"), expected); + } + + #[test] + fn display_kick_member() { + let peer = fixed_peer(); + let action = ProposedAction::KickMember { peer_id: peer }; + let expected = format!("Kick {peer}"); + assert_eq!(format!("{action}"), expected); + } + + #[test] + fn display_set_vote_threshold_majority() { + let action = ProposedAction::SetVoteThreshold { + threshold: VoteThreshold::Majority, + }; + assert_eq!(format!("{action}"), "Set vote threshold to majority"); + } + + #[test] + fn display_set_vote_threshold_unanimous() { + let action = ProposedAction::SetVoteThreshold { + threshold: VoteThreshold::Unanimous, + }; + assert_eq!(format!("{action}"), "Set vote threshold to unanimous"); + } + + #[test] + fn display_set_vote_threshold_count() { + let action = ProposedAction::SetVoteThreshold { + threshold: VoteThreshold::Count(3), + }; + assert_eq!(format!("{action}"), "Set vote threshold to 3 admins"); + } + + #[test] + fn display_vote_threshold_variants() { + assert_eq!(format!("{}", VoteThreshold::Majority), "majority"); + assert_eq!(format!("{}", VoteThreshold::Unanimous), "unanimous"); + assert_eq!(format!("{}", VoteThreshold::Count(0)), "0 admins"); + assert_eq!(format!("{}", VoteThreshold::Count(1)), "1 admins"); + assert_eq!(format!("{}", VoteThreshold::Count(42)), "42 admins"); + } +} diff --git a/crates/web/src/components/file_share.rs b/crates/web/src/components/file_share.rs index 55b6d59f..9f4b7dd2 100644 --- a/crates/web/src/components/file_share.rs +++ b/crates/web/src/components/file_share.rs @@ -66,7 +66,8 @@ pub fn FileShareButton(channel: ReadSignal) -> impl IntoView { let result = match reader_clone.result() { Ok(r) => r, Err(e) => { - tracing::error!("FileReader result error: {e:?}"); + let msg = e.as_string().unwrap_or_else(|| format!("{e:?}")); + tracing::error!(error = %msg, "FileReader result failure"); return; } }; diff --git a/crates/web/src/event_processing.rs b/crates/web/src/event_processing.rs index 77b97ba6..86f02348 100644 --- a/crates/web/src/event_processing.rs +++ b/crates/web/src/event_processing.rs @@ -125,7 +125,9 @@ pub fn process_event_batch( wasm_bindgen_futures::spawn_local(handle_voice_answer(vm, from, s)); } VoiceSignalPayload::IceCandidate(json) => { - let _ = vm.borrow().handle_ice_candidate(&from, json); + if let Err(e) = vm.borrow().handle_ice_candidate(&from, json) { + tracing::warn!(?e, "handle_ice_candidate failed"); + } } } } diff --git a/crates/web/src/util.rs b/crates/web/src/util.rs index 6689e0bb..81d6b23d 100644 --- a/crates/web/src/util.rs +++ b/crates/web/src/util.rs @@ -1,20 +1,46 @@ /// Copy text to the clipboard. /// /// Tries `navigator.clipboard.writeText` first (modern API, requires HTTPS). -/// Falls back to creating a temporary textarea and using `execCommand('copy')`. +/// Falls back to creating a temporary textarea and using `execCommand('copy')` +/// only if the modern API rejects (non-HTTPS, no user gesture, browser +/// restrictions). The signature stays sync so Leptos `on:click` handlers can +/// call it directly; the Promise returned by `writeText` is awaited inside a +/// `spawn_local` task so the fallback never runs on the happy path. pub fn copy_to_clipboard(text: &str) { - use wasm_bindgen::JsCast; - let Some(window) = web_sys::window() else { return; }; - // Try modern clipboard API first. + // Kick off the modern clipboard API and await its Promise. The textarea + // fallback only runs if the Promise rejects. let clipboard = window.navigator().clipboard(); - let _ = clipboard.write_text(text); + let promise = clipboard.write_text(text); + let owned = text.to_owned(); + wasm_bindgen_futures::spawn_local(async move { + match wasm_bindgen_futures::JsFuture::from(promise).await { + Ok(_) => { + tracing::debug!("clipboard.writeText succeeded"); + } + Err(err) => { + tracing::debug!( + ?err, + "clipboard.writeText rejected; using textarea fallback" + ); + exec_command_copy_fallback(&owned); + } + } + }); +} + +/// Legacy clipboard path: append a hidden textarea, select its contents, +/// invoke `document.execCommand('copy')`, and remove the textarea. Only +/// invoked when the modern `navigator.clipboard.writeText` Promise rejects. +fn exec_command_copy_fallback(text: &str) { + use wasm_bindgen::JsCast; - // Also do the textarea fallback in case clipboard API fails silently - // (e.g. non-HTTPS, no user gesture, browser restrictions). + let Some(window) = web_sys::window() else { + return; + }; let Some(document) = window.document() else { return; }; diff --git a/e2e/.wait-timeout-baseline b/e2e/.wait-timeout-baseline index a2720097..f599e28b 100644 --- a/e2e/.wait-timeout-baseline +++ b/e2e/.wait-timeout-baseline @@ -1 +1 @@ -39 +10 diff --git a/e2e/helpers/touch.ts b/e2e/helpers/touch.ts index 71e3c4f0..900e3206 100644 --- a/e2e/helpers/touch.ts +++ b/e2e/helpers/touch.ts @@ -7,6 +7,27 @@ import { Page, Locator } from '@playwright/test'; import { isMobile, visibleShell, openMemberList } from './ui'; +/** + * Mirrors the product-side long-press hold threshold. + * + * Source of truth: `HOLD_MS` in `crates/web/src/components/long_press.rs` + * (currently 350 ms; spec: `docs/specs/2026-04-19-ui-design/trust-verification.md` + * §Long-press SAS on mobile). + * + * Keep this in sync with the product constant. If `HOLD_MS` moves, this + * must move too — otherwise `longPressAvatar` will arm-or-not based on + * whatever buffer is left, producing flaky false negatives (issue #591). + */ +export const LONG_PRESS_MS = 350; + +/** + * Buffer added on top of {@link LONG_PRESS_MS} to absorb scheduler jitter + * under CI load. The total wait is `LONG_PRESS_MS + LONG_PRESS_BUFFER_MS`, + * which crosses the product threshold deterministically without coupling + * tests to the exact threshold value. + */ +export const LONG_PRESS_BUFFER_MS = 250; + /** Simulate a long-press on an element to open the mobile action sheet. * Prefixes the selector with the visible-shell scope so a raw `.message` * picks the mobile copy, not the hidden desktop one. */ @@ -65,7 +86,10 @@ export async function longPress(page: Page, selector: string, durationMs = 600) })); }, { x, y }); - await page.waitForTimeout(300); + // No post-touchend settle: callers must `waitFor` whatever the press + // produces (typically `.shell-mobile .mobile-action-sheet.open`). A + // bare `waitForTimeout` here was unobservable dead weight and coupled + // the gesture helper to a single outcome (issue #590). } /** @@ -137,8 +161,20 @@ export async function longPressWithClock(page: Page, selector: string, durationM await page.clock.runFor(300); } -/** Long-press a peer avatar by name in the member list (mobile only). */ -export async function longPressAvatar(page: Page, peerName: string) { +/** Long-press a peer avatar by name in the member list (mobile only). + * + * The hold duration is parameterised over {@link LONG_PRESS_MS} (mirror + * of the product's `HOLD_MS`) plus {@link LONG_PRESS_BUFFER_MS}. Callers + * may override `durationMs` if they need a different timing, but the + * default crosses the product threshold deterministically — no longer + * coupled to a bare `500` literal that could fall under the threshold + * if `HOLD_MS` ever shifts up (issue #591). + */ +export async function longPressAvatar( + page: Page, + peerName: string, + durationMs: number = LONG_PRESS_MS + LONG_PRESS_BUFFER_MS, +) { await openMemberList(page); const member = page.locator(`${visibleShell(page)} .member-item`, { hasText: peerName }); await member.waitFor({ timeout: 10_000 }); @@ -147,7 +183,7 @@ export async function longPressAvatar(page: Page, peerName: string) { if (!box) throw new Error('avatar not measurable'); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); - await page.waitForTimeout(500); + await page.waitForTimeout(durationMs); await page.mouse.up(); } diff --git a/e2e/helpers/ui.ts b/e2e/helpers/ui.ts index 767f12e7..ec0fd33f 100644 --- a/e2e/helpers/ui.ts +++ b/e2e/helpers/ui.ts @@ -158,7 +158,12 @@ export async function switchTab( ) { if (!isMobile(page)) return; await page.locator(`.mobile-tab-bar .tab[data-tab="${tabId}"]`).click(); - await page.waitForTimeout(200); + // Tab activation flips `aria-selected` on the target button — wait for + // that rather than a fixed sleep so the transition settles before the + // caller starts interacting with the new tab's panel. + await page + .locator(`.mobile-tab-bar .tab[data-tab="${tabId}"][aria-selected="true"]`) + .waitFor({ state: 'visible', timeout: 3_000 }); } /** Opens the member list in the right rail. On desktop clicks the @@ -256,16 +261,24 @@ export async function messageAction(page: Page, messageText: string, actionName: await page .locator('.shell-mobile .mobile-action-sheet.open .sheet-item', { hasText: actionRe }) .click(); - await page.waitForTimeout(300); + // Sheet item drops the `.open` class — wait for the open-sheet + // selector to disappear instead of a fixed sleep. + await page + .locator('.shell-mobile .mobile-action-sheet.open') + .waitFor({ state: 'hidden', timeout: 3_000 }); } else { const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); // Desktop: hover to reveal action trigger, click dropdown item. await msg.hover(); - await page.waitForTimeout(200); + await msg.locator('.action-trigger').waitFor({ state: 'visible', timeout: 3_000 }); await msg.locator('.action-trigger').click(); - await page.waitForTimeout(200); + await page + .locator('.dropdown-item', { hasText: actionName }) + .waitFor({ state: 'visible', timeout: 3_000 }); await page.locator('.dropdown-item', { hasText: actionName }).click(); - await page.waitForTimeout(200); + // Clicking a dropdown-item closes the dropdown (set_show_dropdown + // → false in message.rs); gate on the items unmounting. + await page.locator('.dropdown-item').first().waitFor({ state: 'hidden', timeout: 3_000 }); } } @@ -275,7 +288,10 @@ export async function editMessage(page: Page, originalText: string, newText: str const input = page.locator('.input-area input, .input-area textarea').first(); await input.fill(newText); await input.press('Enter'); - await page.waitForTimeout(500); + // Submitting clears the editing signal — the `.edit-bar` (rendered + // only while editing in input.rs) unmounts. Gate on that instead of + // a fixed sleep. + await page.locator(`${visibleShell(page)} .edit-bar`).waitFor({ state: 'hidden', timeout: 5_000 }); } /** Deletes a message (desktop or mobile). */ @@ -285,7 +301,9 @@ export async function deleteMessage(page: Page, text: string) { const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Delete' }); await confirmBtn.waitFor({ timeout: 3000 }); await confirmBtn.click(); - await page.waitForTimeout(500); + // Confirmation closes the dialog — wait for the overlay to unmount + // rather than a fixed sleep. + await page.locator('.confirm-dialog').waitFor({ state: 'hidden', timeout: 5_000 }); } /** Reacts to a message with an emoji (desktop or mobile). */ @@ -296,17 +314,30 @@ export async function reactToMessage(page: Page, messageText: string, emojiIndex .waitFor({ timeout: 3000 }); await page.locator('.shell-mobile .mobile-action-sheet.open .sheet-emoji-row button') .nth(emojiIndex).click(); - await page.waitForTimeout(500); + // Tapping a quick-emoji button closes the sheet — gate on the + // open-class disappearing instead of a fixed sleep. + await page + .locator('.shell-mobile .mobile-action-sheet.open') + .waitFor({ state: 'hidden', timeout: 3_000 }); } else { const msg = page.locator('.shell-desktop .message', { hasText: messageText }).last(); await msg.hover(); - await page.waitForTimeout(200); + await msg.locator('.action-trigger').waitFor({ state: 'visible', timeout: 3_000 }); await msg.locator('.action-trigger').click(); - await page.waitForTimeout(200); + await page + .locator('.dropdown-item', { hasText: 'React' }) + .waitFor({ state: 'visible', timeout: 3_000 }); await page.locator('.dropdown-item', { hasText: 'React' }).click(); - await page.waitForTimeout(200); + // Clicking React reveals the dropdown emoji row — wait for the + // first emoji button to mount before clicking by index. + await page + .locator('.dropdown-emoji-row button') + .first() + .waitFor({ state: 'visible', timeout: 3_000 }); await page.locator('.dropdown-emoji-row button').nth(emojiIndex).click(); - await page.waitForTimeout(500); + // Selecting an emoji closes the dropdown — gate on the items + // unmounting rather than a fixed sleep. + await page.locator('.dropdown-item').first().waitFor({ state: 'hidden', timeout: 3_000 }); } } @@ -319,6 +350,12 @@ export async function trustPeer(page: Page, peerName: string) { await member.hover(); // Use a regex to avoid matching "Untrust" when looking for "Trust". await member.locator('button').filter({ hasText: /^Trust$/ }).click(); + // TODO(#589): no-condition wait — needs design. Trust dispatches a + // `propose_grant_admin` event whose effect (target peer's row picking + // up the "Trusted" badge) only materialises once the proposed-action + // vote applies, which is async / multi-peer dependent. The button + // itself doesn't change locally on click. Callers verify post-trust + // behaviour separately, so the prior 500ms sleep was a fudge factor. await page.waitForTimeout(500); await closeMemberList(page); } @@ -331,6 +368,9 @@ export async function untrustPeer(page: Page, peerName: string) { // Hover to reveal action buttons (desktop hides them until hover). await member.hover(); await member.locator('button', { hasText: 'Untrust' }).click(); + // TODO(#589): no-condition wait — needs design. Same reasoning as + // `trustPeer` above: `propose_revoke_admin` is async and the local + // row doesn't flip until the proposal vote applies. await page.waitForTimeout(500); await closeMemberList(page); } @@ -377,11 +417,14 @@ export async function kickPeer(page: Page, peerName: string) { // Hover to reveal action buttons (desktop hides them until hover). await member.hover(); await member.locator('.btn-danger', { hasText: 'Kick' }).click(); - await page.waitForTimeout(500); + // The Kick click opens the confirm dialog; the next `confirmBtn.waitFor` + // already gates on it appearing, so the prior 500ms sleep was redundant. // Confirm the kick dialog. const confirmBtn = page.locator('.confirm-dialog .btn-danger', { hasText: 'Kick' }); await confirmBtn.waitFor({ timeout: 5_000 }); await confirmBtn.click(); - await page.waitForTimeout(500); + // Confirmation closes the dialog — gate on the overlay unmounting + // rather than a fixed sleep before closing the member list. + await page.locator('.confirm-dialog').waitFor({ state: 'hidden', timeout: 5_000 }); await closeMemberList(page); } diff --git a/e2e/permissions.spec.ts b/e2e/permissions.spec.ts index 7c8906c2..64a0d79b 100644 --- a/e2e/permissions.spec.ts +++ b/e2e/permissions.spec.ts @@ -9,7 +9,6 @@ import { openCompareFingerprints, markFingerprintsMatch, markFingerprintsMismatch, - longPressAvatar, visibleShell, } from './helpers'; @@ -29,10 +28,9 @@ test.describe('Permissions and trust', () => { // Mobile member-list surface is deferred to a later phase (Phase 1b // shipped the mobile shell without the right-rail members pane). - // Kick tests go through `.member-item`, which only renders on desktop - // today. Long-press / compare-sheet tests below opt back in explicitly - // since they drive trust via the compare-fingerprints sheet, not the - // member list. + // Kick + compare-sheet tests go through `.member-item`, which only + // renders on desktop today, so they're skipped on mobile projects. + // Mobile long-press coverage tracked in #595. // // Trust / untrust tests that used to live here (Unknown → Verified // and badge-render contracts) moved to: @@ -235,20 +233,4 @@ test.describe('Permissions and trust', () => { await ctx2.close(); } }); - - test('mobile long-press opens the compare sheet', async ({ browser }, testInfo) => { - test.skip(!testInfo.project.name.startsWith('mobile'), 'mobile-chrome path'); - // Long-press targets a member avatar; the mobile member surface - // lands in a follow-up phase. Skip until that ships. - test.skip(true, 'mobile member-list surface deferred'); - const { ctx1, ctx2, page1 } = await setupTwoPeers(browser, 'LongPress', 'Alice', 'Bob'); - try { - await longPressAvatar(page1, 'Bob'); - await expect(page1.locator('.add-friend__card[role="dialog"]')) - .toBeVisible({ timeout: 10_000 }); - } finally { - await ctx1.close(); - await ctx2.close(); - } - }); });