npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.

> sawaup@3.6.1 test
> node --test tests/*.test.mjs

✔ Athar profile has Wamda publish, notifications, DMs and public/private control (0.796236ms)
✔ right swipe returns to phone chats and left swipe opens Athar profile (0.165559ms)
✔ private Athar follows use requests with accept and reject (0.138799ms)
✔ Athar direct messages are separate backend records (0.124267ms)
✔ private Wamdat are filtered from discovery unless viewer is accepted follower (0.144828ms)
✔ background notification task is registered and stores messages locally (1.343229ms)
✔ push transport sends a visible alert plus a high-priority headless cache copy (0.263066ms)
✔ Android separates message and call notification channels (0.142354ms)
✔ message and call pushes carry enough payload for local cache and routing (0.187662ms)
✔ new groups notify initial members immediately (0.13932ms)
✔ cache serializes concurrent merges without losing messages (9.284962ms)
✔ cache does not silently erase corrupt local history (0.558879ms)
✖ caller negotiates video without opening camera and requests earpiece (3.731997ms)
✖ callee uses the offered video sender instead of adding an unmatched transceiver (0.87185ms)
✖ audio track arriving after video preserves both remote tracks (0.715385ms)
✔ calls keep STUN and support authenticated multi-endpoint TURN fallback (2.05598ms)
✖ unified call starts audio-only and can enable camera without starting a new call (2.303812ms)
✔ 200 filter/lifecycle cycles retain one camera component and one native output per mount (33.164558ms)
✔ 40 open/close mounts each dispose their processing output (24.875668ms)
✔ Android geometric filters never render the decorative FaceEffect fallback (1.045041ms)
✔ Arabic digits and Oman phone normalize (0.992301ms)
✔ username normalize (0.361143ms)
✔ profile search accepts username and phone (0.869277ms)
✔ poll and event previews are readable (0.214011ms)
✔ attachment parsing (0.107011ms)
✔ audio preview (0.104437ms)
✔ message merge keeps receipts and local media (0.181923ms)
✔ one-view effects cannot reopen after consumption (0.154813ms)
✔ room recipients are unique and include sender (0.369125ms)
✔ room relay rejects a sender who is not a member (0.594062ms)
✔ v2.4 SQL migration defines durable poll votes and event RSVP tables (0.371588ms)
✔ v2.5 group moderation migration adds durable muted state (0.132619ms)
✔ Athar uses the account-wide username and does not expose a separate editable username (1.113453ms)
✔ keyboard uses one resize policy and keeps active forms scrollable above the keyboard (0.977339ms)
✔ Android push registration diagnoses native token and retries Expo token (0.729667ms)
✔ slow Y distance locks before release regardless of speed or original button bounds (2.977995ms)
✔ release event reaching threshold locks even if no move event was delivered (0.679031ms)
✔ release below threshold sends once despite duplicate release events (0.837118ms)
✔ release during permission is honored after preparation (0.779171ms)
✔ listen once is carried in the sent attachment options, preview is optional (0.906582ms)
✔ system gesture cancellation discards recording (0.714934ms)
✔ inbox reads stored wamda likes/comments with actor profiles and retains follows (7.704977ms)
✔ public map retry reuses uploaded media and record id after metadata network error (1.591201ms)
✔ invalid coordinates fail before upload (0.806021ms)
✔ AccountScreen does not shadow the imported setBlocked service (1.651982ms)
✔ unblock button calls the service then refreshes the local blocked list (0.3593ms)
✔ all JSX press controls are wired to handlers (1.128997ms)
✔ direct chat plus menu exposes the seven requested actions (0.630126ms)
✔ contact picker searches by phone or username (0.268323ms)
✔ group plus menu exposes the same seven requested actions (1.147023ms)
✔ poll cards support voting, changing vote, counts and percentages (0.423006ms)
✔ event cards support RSVP and add-to-calendar (0.34589ms)
✔ bottom navigation has no rooms tab and keeps chats as launch screen (1.301276ms)
✔ chat composers use Android resize so the full composer clears the keyboard (0.708134ms)
✔ groups live inside chats and support immediate moderation (0.393051ms)
✔ calls use one confirmation action and camera is enabled inside the call (0.710017ms)
✔ 24-hour Athar prefers durable local media after publish (0.181753ms)
✔ notification responses deep-link to chats, groups, calls (0.53893ms)
✔ returning from another app repaints chats from local phone storage before network (0.145178ms)
✔ voice recorder uses hold-to-record, slide-up lock, review, delete and send (0.291107ms)
✖ unified call starts audio-only and negotiates dormant video before offer (2.220677ms)
✖ camera is attached on demand to the pre-negotiated video sender (0.686631ms)
✔ 3.1.2 implements owner-only public Athar deletion (1.20439ms)
✔ 3.1.2 implements owner-only Wamda deletion and UI removal (0.190967ms)
✔ 3.1.2 downloads Wamda media into the device library without bypassing reshare choice (0.254072ms)
✔ 3.1.2 keeps Wamda pin migration and media-library config in the full build (0.306501ms)
✔ 3.1.2 hardens active test-stage RLS away from bare true policies (0.218689ms)
✔ Storage requests send the same test-stage gate key required by media RLS (0.241563ms)
✔ 3.1.1 autolinks the local isolated face-warp Expo module (1.0645ms)
✔ Android face warp consumes a VisionCamera output without a second camera owner (0.41311ms)
✔ native face analysis prefers 468-point Face Mesh and falls back to contours for unsafe or missed frames (0.328423ms)
✔ camera buffers are copied and EGL finishes before output texture is destroyed (0.343055ms)
✔ GPU renderer performs inverse pixel deformation instead of overlay-only drawing (0.324768ms)
✔ mesh-guided deformation uses feature-specific radii instead of one generic face box (0.225569ms)
✔ all agreed geometry-changing filters have native warp parameter presets (5.861588ms)
✔ recoverable warp error leaves the existing camera mounted and blocks invalid filtered capture (0.342014ms)
✔ normal and decorative camera paths remain available and filtered media records the visible composition (0.251858ms)
✔ face warp module is Android-first and iOS remains a safe fallback (1.089166ms)
✔ frame rotation comes from the owner and torch stays on its active controller (0.29241ms)
✔ 3.1 keeps direct and group swipe reply plus sticker sending (1.115737ms)
✔ 3.1 conversation camera is wired for direct and group capture/review (1.672202ms)
✔ 3.1 notification reply and previews cover message variants (0.283336ms)
✔ 3.1 keyboard fix uses native Android resize rather than the old insufficient height offset (0.259971ms)
✖ 3.1 call path hardens permissions, startup timeouts, actual connection state and self view (1.846174ms)
✔ 3.1 Athar trend, pull refresh and pinned Wamdat are wired (0.294523ms)
✔ 3.1 keeps the stable simple receipt UI (0.238779ms)
✔ interactive message notifications register a native text reply action (1.392653ms)
✔ main profile save verifies and caches the server result (0.284989ms)
✔ Athar profile save verifies persisted bio and follow lists resolve missing Athar profiles (0.214382ms)
✔ friend Athar recipient picker hydrates cached people and groups before network refresh (0.254012ms)
✔ Athar profile exposes chat, Wamdat and working menu actions (0.189244ms)
✔ voice recording keeps the requested hold-release-lock-review gesture (0.292159ms)
✔ 3.1 preserves the confirmed successful chat improvements (1.374325ms)
✔ 3.1 fixes the reported chat regressions without restoring failed receipt text (0.37905ms)
✔ call timeline groups missed calls and formats answered duration (10.645375ms)
✔ full 3.1 build advances beyond the failed build 18 (0.212099ms)
✔ Athar high-volume lists use bounded pagination instead of 1000/5000 row pulls (0.958891ms)
✔ Athar profile counters use HEAD exact-count queries instead of downloading rows (0.229424ms)
✔ friend Athar and public Athar are separate entry points (1.356498ms)
✔ friend story + badge is lower-left (0.190686ms)
✔ simple mode hides only the public Athar tab (0.307372ms)
✔ Athar camera has glass search and four-way gestures (0.263777ms)
✔ Wamdat is a vertical paged video feed with search discovery (0.244207ms)
✔ public Athar profile is independent and supports follows (0.170185ms)
✔ public map stores approximate coordinates only from opt-in publish (0.181793ms)
✔ public reshares do not let friend-story cleanup delete shared media (0.358609ms)
✔ friend Athar create button opens live camera instead of treating press event as media (0.477378ms)
✔ v3 uses MapLibre with New Architecture and no react-native-maps dependency (0.878479ms)
✔ Athar live camera is driven by VisionCamera face detection during preview (0.205949ms)
✔ camera wrapper saves the same live filter composition for photos and videos (0.173391ms)
✔ Athar map clusters nearby public posts and expands clusters on tap (0.125579ms)
✔ all 20 agreed Athar filter names are present (0.182785ms)
✔ push delivery has visible OS alerts, background cache copy, call channel, and registration diagnostics (0.174552ms)
✔ native build number identifies the full 3.1 repository build (0.107462ms)
✔ voice hold release keeps immediate-send behavior from the working v3.0.0 path (2.537553ms)
✔ slide-up locks recording and provides recipient listen-once option (0.268504ms)
✔ recording composer replaces the text field with waveform and elapsed duration (0.28562ms)
✔ audio uploads use an audio MIME type instead of falling through to image/jpeg (0.158909ms)
ℹ tests 118
ℹ suites 0
ℹ pass 111
ℹ fail 7
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 580.887899

✖ failing tests:

test at tests/call-behavior.test.mjs:20:1
✖ caller negotiates video without opening camera and requests earpiece (3.731997ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:20:95)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.start (node:internal/test_runner/test:1242:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)

test at tests/call-behavior.test.mjs:21:1
✖ callee uses the offered video sender instead of adding an unmatched transceiver (0.87185ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:21:106)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)

test at tests/call-behavior.test.mjs:22:1
✖ audio track arriving after video preserves both remote tracks (0.715385ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-behavior.test.mjs:22:88)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7)

test at tests/call-network-regression.test.mjs:15:1
✖ unified call starts audio-only and can enable camera without starting a new call (2.303812ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: true, video: false \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/call-network-regression.test.mjs:16:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: true, video: false \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/unified-call-final.test.mjs:7:1
✖ unified call starts audio-only and negotiates dormant video before offer (2.220677ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: true, video: false \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/unified-call-final.test.mjs:8:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.start (node:internal/test_runner/test:1242:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: true, video: false \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/unified-call-final.test.mjs:14:1
✖ camera is attached on demand to the pre-negotiated video sender (0.686631ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: false, video: \{ facingMode: 'user' \} \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/unified-call-final.test.mjs:15:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: false, video: \{ facingMode: 'user' \} \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-regressions.test.mjs:53:1
✖ 3.1 call path hardens permissions, startup timeouts, actual connection state and self view (1.846174ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /ensureCallPermissions/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/capture-work/tests/v3-1-regressions.test.mjs:54:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /ensureCallPermissions/,
    operator: 'match',
    diff: 'simple'
  }
