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.775596ms)
✔ right swipe returns to phone chats and left swipe opens Athar profile (0.142218ms)
✔ private Athar follows use requests with accept and reject (0.139143ms)
✔ Athar direct messages are separate backend records (0.126716ms)
✔ private Wamdat are filtered from discovery unless viewer is accepted follower (0.174275ms)
✔ background notification task is registered and stores messages locally (0.768936ms)
✔ push transport sends a visible alert plus a high-priority headless cache copy (0.181515ms)
✔ Android separates message and call notification channels (0.125303ms)
✔ message and call pushes carry enough payload for local cache and routing (0.179122ms)
✔ new groups notify initial members immediately (0.174206ms)
✔ cache serializes concurrent merges without losing messages (9.843283ms)
✔ cache does not silently erase corrupt local history (0.752532ms)
✖ caller negotiates video without opening camera and requests earpiece (3.797841ms)
✖ callee uses the offered video sender instead of adding an unmatched transceiver (1.004892ms)
✖ audio track arriving after video preserves both remote tracks (0.926266ms)
✔ calls keep STUN and support authenticated multi-endpoint TURN fallback (0.913658ms)
✖ unified call starts audio-only and can enable camera without starting a new call (2.436057ms)
✔ Arabic digits and Oman phone normalize (1.429924ms)
✔ username normalize (0.274011ms)
✔ profile search accepts username and phone (1.308416ms)
✔ poll and event previews are readable (0.912186ms)
✔ attachment parsing (0.169718ms)
✔ audio preview (0.197289ms)
✔ message merge keeps receipts and local media (0.285428ms)
✔ one-view effects cannot reopen after consumption (0.234383ms)
✔ room recipients are unique and include sender (0.543095ms)
✔ room relay rejects a sender who is not a member (0.586689ms)
✔ v2.4 SQL migration defines durable poll votes and event RSVP tables (0.600009ms)
✔ v2.5 group moderation migration adds durable muted state (0.195136ms)
✔ Athar uses the account-wide username and does not expose a separate editable username (0.700877ms)
✔ keyboard uses one resize policy and keeps active forms scrollable above the keyboard (1.198474ms)
✔ Android push registration diagnoses native token and retries Expo token (0.971362ms)
✔ slow Y distance locks before release regardless of speed or original button bounds (3.119057ms)
✔ release event reaching threshold locks even if no move event was delivered (0.782115ms)
✔ release below threshold sends once despite duplicate release events (0.967126ms)
✔ release during permission is honored after preparation (0.818528ms)
✔ listen once is carried in the sent attachment options, preview is optional (1.032192ms)
✔ system gesture cancellation discards recording (0.698914ms)
✔ inbox reads stored wamda likes/comments with actor profiles and retains follows (7.544187ms)
✔ public map retry reuses uploaded media and record id after metadata network error (1.845813ms)
✔ invalid coordinates fail before upload (0.857897ms)
✔ AccountScreen does not shadow the imported setBlocked service (1.854095ms)
✔ unblock button calls the service then refreshes the local blocked list (0.369671ms)
✔ all JSX press controls are wired to handlers (1.23577ms)
✔ direct chat plus menu exposes the seven requested actions (0.531468ms)
✔ contact picker searches by phone or username (0.189587ms)
✔ group plus menu exposes the same seven requested actions (0.893959ms)
✔ poll cards support voting, changing vote, counts and percentages (0.525439ms)
✔ event cards support RSVP and add-to-calendar (0.418563ms)
✔ bottom navigation has no rooms tab and keeps chats as launch screen (0.77833ms)
✔ chat composers use Android resize so the full composer clears the keyboard (0.734766ms)
✔ groups live inside chats and support immediate moderation (0.363132ms)
✔ calls use one confirmation action and camera is enabled inside the call (0.637323ms)
✔ 24-hour Athar prefers durable local media after publish (0.18448ms)
✔ notification responses deep-link to chats, groups, calls (0.516496ms)
✔ returning from another app repaints chats from local phone storage before network (0.175837ms)
✔ voice recorder uses hold-to-record, slide-up lock, review, delete and send (0.346146ms)
✖ unified call starts audio-only and negotiates dormant video before offer (2.653726ms)
✖ camera is attached on demand to the pre-negotiated video sender (0.651614ms)
✔ 3.1.2 implements owner-only public Athar deletion (0.804688ms)
✔ 3.1.2 implements owner-only Wamda deletion and UI removal (0.139774ms)
✔ 3.1.2 downloads Wamda media into the device library without bypassing reshare choice (0.164621ms)
✔ 3.1.2 keeps Wamda pin migration and media-library config in the full build (0.199131ms)
✔ 3.1.2 hardens active test-stage RLS away from bare true policies (0.453654ms)
✔ Storage requests send the same test-stage gate key required by media RLS (0.169007ms)
✔ 3.1.1 autolinks the local isolated face-warp Expo module (1.129534ms)
✔ Android face warp owns a separate CameraX + ML Kit pipeline with frame dropping (0.231809ms)
✔ native face analysis prefers 468-point Face Mesh and falls back to contours for unsafe or missed frames (0.226442ms)
✔ CameraX surface lifetime is owned until SurfaceRequest detaches (0.117241ms)
✔ GPU renderer performs inverse pixel deformation instead of overlay-only drawing (0.316373ms)
✔ mesh-guided deformation uses feature-specific radii instead of one generic face box (0.175547ms)
✔ all agreed geometry-changing filters have native warp parameter presets (0.140646ms)
✔ warp failure fails open to the stable VisionCamera camera path (0.163349ms)
✔ normal and decorative camera paths remain available and filtered media records the visible composition (0.334881ms)
✔ face warp module is Android-first and iOS remains a safe fallback (0.780082ms)
✔ Android native warp pins display rotation and guards torch until CameraX is OPEN (0.266781ms)
✔ 3.1 keeps direct and group swipe reply plus sticker sending (1.091118ms)
✔ 3.1 conversation camera is wired for direct and group capture/review (0.295813ms)
✔ 3.1 notification reply and previews cover message variants (0.353778ms)
✔ 3.1 keyboard fix uses native Android resize rather than the old insufficient height offset (0.231068ms)
✖ 3.1 call path hardens permissions, startup timeouts, actual connection state and self view (2.828391ms)
✔ 3.1 Athar trend, pull refresh and pinned Wamdat are wired (0.256906ms)
✔ 3.1 keeps the stable simple receipt UI (0.385865ms)
✔ interactive message notifications register a native text reply action (0.970631ms)
✔ main profile save verifies and caches the server result (0.139123ms)
✔ Athar profile save verifies persisted bio and follow lists resolve missing Athar profiles (0.135048ms)
✔ friend Athar recipient picker hydrates cached people and groups before network refresh (0.200644ms)
✔ Athar profile exposes chat, Wamdat and working menu actions (0.205661ms)
✔ voice recording keeps the requested hold-release-lock-review gesture (0.216477ms)
✔ 3.1 preserves the confirmed successful chat improvements (1.229721ms)
✔ 3.1 fixes the reported chat regressions without restoring failed receipt text (0.34837ms)
✔ call timeline groups missed calls and formats answered duration (8.929865ms)
✔ full 3.1 build advances beyond the failed build 18 (0.16365ms)
✔ Athar high-volume lists use bounded pagination instead of 1000/5000 row pulls (0.842203ms)
✔ Athar profile counters use HEAD exact-count queries instead of downloading rows (0.355621ms)
✔ friend Athar and public Athar are separate entry points (1.059491ms)
✔ friend story + badge is lower-left (0.143481ms)
✔ simple mode hides only the public Athar tab (0.202076ms)
✔ Athar camera has glass search and four-way gestures (0.203017ms)
✔ Wamdat is a vertical paged video feed with search discovery (0.212612ms)
✔ public Athar profile is independent and supports follows (0.115529ms)
✔ public map stores approximate coordinates only from opt-in publish (0.194004ms)
✔ public reshares do not let friend-story cleanup delete shared media (0.287701ms)
✔ friend Athar create button opens live camera instead of treating press event as media (0.38884ms)
✔ v3 uses MapLibre with New Architecture and no react-native-maps dependency (1.124707ms)
✔ Athar live camera is driven by VisionCamera face detection during preview (0.292489ms)
✔ camera wrapper saves the same live filter composition for photos and videos (0.188286ms)
✔ Athar map clusters nearby public posts and expands clusters on tap (0.122098ms)
✔ all 20 agreed Athar filter names are present (0.235655ms)
✔ push delivery has visible OS alerts, background cache copy, call channel, and registration diagnostics (0.181036ms)
✔ native build number identifies the full 3.1 repository build (0.112394ms)
✔ voice hold release keeps immediate-send behavior from the working v3.0.0 path (1.13395ms)
✔ slide-up locks recording and provides recipient listen-once option (0.194635ms)
✔ recording composer replaces the text field with waveform and elapsed duration (0.224559ms)
✔ audio uploads use an audio MIME type instead of falling through to image/jpeg (0.118173ms)
ℹ tests 115
ℹ suites 0
ℹ pass 108
ℹ fail 7
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 568.151164

✖ failing tests:

test at tests/call-behavior.test.mjs:20:1
✖ caller negotiates video without opening camera and requests earpiece (3.797841ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///tmp/sawaup-v4-baseline/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///tmp/sawaup-v4-baseline/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 (1.004892ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///tmp/sawaup-v4-baseline/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///tmp/sawaup-v4-baseline/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.926266ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///tmp/sawaup-v4-baseline/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///tmp/sawaup-v4-baseline/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.436057ms)
  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:///tmp/sawaup-v4-baseline/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.653726ms)
  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:///tmp/sawaup-v4-baseline/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.651614ms)
  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:///tmp/sawaup-v4-baseline/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 (2.828391ms)
  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:///tmp/sawaup-v4-baseline/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'
  }
