diff --git a/docs/project-memory.md b/docs/project-memory.md index c08cfef..e72394d 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -33,6 +33,7 @@ Last updated: 2026-08-19 - The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`. - Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers. - Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified. +- The stacked `feat/simple-entry-diagnostics` Draft puts equal share/join actions inside the idle 16:9 stage, defaults Host/Viewer technical details closed with one native checkbox, keeps actionable warnings outside it, and uses the bounded waiting/recovery/closed copy. Chrome 151 CDP checks pass at 320/375/390 CSS px without horizontal overflow; it is not deployed. - Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map. - With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms. - Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled. diff --git a/docs/status.md b/docs/status.md index d875935..4be93d6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,6 +22,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share. - A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child. - Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children. +- The stacked `feat/simple-entry-diagnostics` Draft places equal share/join actions in the idle 16:9 stage, removes the duplicate weak entry, and defaults Host/Viewer technical details closed while keeping actionable warnings visible. It is not deployed. ## Verified Evidence @@ -31,6 +32,8 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed - A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding. - Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds. - Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene. +- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media. +- The stacked UI branch passes type checking, 20 Vitest files/254 tests, both production builds, and the production dependency audit. - Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E. - Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages. - Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled. @@ -48,9 +51,9 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed ## Next Milestone -Finish ADR-0005 before UI polish: address the 2.257-second fallback, then -validate public transports, rollback, reconnect, controls, edge counts, egress, -and load. +Finish ADR-0005: address the 2.257-second fallback, then validate public +transports, rollback, reconnect, controls, edge counts, egress, and load. Keep +the stacked entry/diagnostics Draft separate and unmerged until its parent lands. ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network, resource, quality, latency, recovery, and browser/mobile-leaf matrix. diff --git a/docs/方案设计.md b/docs/方案设计.md index a7a4c52..b80d025 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -265,7 +265,7 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 ## 官方与参考资料 -访问日期均为 2026-08-18: +访问日期为 2026-08-18 至 2026-08-19: - [Vite Getting Started](https://vite.dev/guide/) - [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch) @@ -282,6 +282,8 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 - [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00) - [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/) - [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html) +- [W3C WAI: Labeling Controls](https://www.w3.org/WAI/tutorials/forms/labels/) +- [W3C WCAG 2.2: Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced) MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。 diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts index 0d64719..c220812 100644 --- a/scripts/peer-assisted-benchmark.ts +++ b/scripts/peer-assisted-benchmark.ts @@ -1416,6 +1416,20 @@ async function runQualityControlSmoke( .map((page) => page.label), ); const sendingPages = pages.filter((page) => sendingLabels.has(page.label)); + await Promise.all( + sendingPages.map((page) => + evaluate( + cdp, + page, + `(() => { + const toggle = document.querySelector('.connection-details-toggle input'); + if (!(toggle instanceof HTMLInputElement)) return false; + if (!toggle.checked) toggle.click(); + return true; + })()`, + ), + ), + ); const waitForReadback = async (label: "平衡" | "清晰"): Promise => { const expected = JSON.stringify(`${label} / ${label}`); await Promise.all( diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx index 8eaea5f..9d17ea5 100644 --- a/src/client/components/AppHeader.tsx +++ b/src/client/components/AppHeader.tsx @@ -10,7 +10,7 @@ export function AppHeader({ status }: { status: ReactNode }) { Screener -
{status}
+ {status &&
{status}
} ); } diff --git a/src/client/components/ConnectionDetailsToggle.tsx b/src/client/components/ConnectionDetailsToggle.tsx new file mode 100644 index 0000000..f94d5db --- /dev/null +++ b/src/client/components/ConnectionDetailsToggle.tsx @@ -0,0 +1,27 @@ +import type { ChangeEvent } from "react"; + +interface ConnectionDetailsToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; +} + +export function ConnectionDetailsToggle({ + checked, + onChange, +}: ConnectionDetailsToggleProps) { + return ( + + ); +} diff --git a/src/client/components/StatusBadge.tsx b/src/client/components/StatusBadge.tsx index 8766e09..4459061 100644 --- a/src/client/components/StatusBadge.tsx +++ b/src/client/components/StatusBadge.tsx @@ -39,7 +39,7 @@ export function PeerStatusBadge({ state: RTCPeerConnectionState | "waiting"; }) { const labels: Record = { - waiting: { tone: "neutral", label: "等待画面" }, + waiting: { tone: "neutral", label: "等待开始分享" }, new: { tone: "neutral", label: "准备中" }, connecting: { tone: "neutral", label: "正在连接" }, connected: { tone: "good", label: "已连接" }, diff --git a/src/client/components/connection-details.ts b/src/client/components/connection-details.ts new file mode 100644 index 0000000..507c07e --- /dev/null +++ b/src/client/components/connection-details.ts @@ -0,0 +1,12 @@ +import type { PeerSnapshot } from "../types"; + +export function qualityLimitationSummary( + snapshots: readonly PeerSnapshot[], +): string | null { + for (const snapshot of snapshots) { + if (snapshot.qualityWarning) { + return snapshot.qualityWarning; + } + } + return null; +} diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index e667e74..9f6e44e 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -17,6 +17,8 @@ import { type ServerMessage, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; +import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; +import { qualityLimitationSummary } from "../components/connection-details"; import { PathBadge, PeerStatusBadge, @@ -143,6 +145,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { const [switchingSource, setSwitchingSource] = useState(false); const [changingQuality, setChangingQuality] = useState(false); const [picturePaused, setPicturePaused] = useState(false); + const [showConnectionDetails, setShowConnectionDetails] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); @@ -167,6 +170,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { () => matchingQualityProfileId(qualitySettings), [qualitySettings], ); + const qualityLimitation = useMemo( + () => qualityLimitationSummary(viewers), + [viewers], + ); useEffect(() => { if (videoRef.current) { @@ -1024,7 +1031,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { return (
- } /> + + ) : null + } + />
@@ -1035,13 +1048,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {phase === "live" ? `${viewers.length}/${maxViewers ?? "-"} 人正在观看` : phase === "starting" - ? "正在建立房间" - : room - ? "房间已就绪" - : "尚未开始"} + ? "正在连接" + : phase === "ended" && room + ? "已停止分享" + : room + ? "房间已就绪" + : "尚未开始"}

- {forceRelay && 强制中继} + {showConnectionDetails && forceRelay && ( + 强制中继 + )} {(phase === "live" || phase === "starting") && (
{phase === "live" && ( @@ -1088,13 +1105,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { )}
-
+
{stream ? (
- {details && stream && ( + {showConnectionDetails && details && stream && (
{details.resolution} {details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"} @@ -1119,15 +1169,26 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { {!details?.hasAudio && stream && ( 当前来源没有可共享音频 )} - {room && signalStatus === "connected" && !relayAvailable && ( + {showConnectionDetails && + room && + signalStatus === "connected" && + !relayAvailable && ( TURN 未配置,严格网络可能无法连接 )} + {qualityLimitation && ( + {qualityLimitation} + )} {notice && (
{notice}
)} + +
@@ -1272,24 +1333,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
- - {phase !== "live" && ( - - )} - {phase !== "live" && ( - - - )}
{room && (
@@ -1331,17 +1374,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {

朋友 {index + 1}

- + {showConnectionDetails && ( + + )}
- - {viewer.error &&

{viewer.error}

} - {viewer.qualityWarning && ( -

{viewer.qualityWarning}

+ {showConnectionDetails && ( + )} + {viewer.error &&

{viewer.error}

} ))} {viewers.length === 0 && ( diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx index 74f0953..121f139 100644 --- a/src/client/pages/ViewerPage.tsx +++ b/src/client/pages/ViewerPage.tsx @@ -14,6 +14,8 @@ import { type ServerMessage, } from "../../shared/protocol"; import { AppHeader } from "../components/AppHeader"; +import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle"; +import { qualityLimitationSummary } from "../components/connection-details"; import { PathBadge, PeerStatusBadge, @@ -52,7 +54,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) ); const [signalStatus, setSignalStatus] = useState("offline"); - const [statusText, setStatusText] = useState("正在进入房间"); + const [statusText, setStatusText] = useState("正在连接"); const [hostOnline, setHostOnline] = useState(false); const [relayAvailable, setRelayAvailable] = useState(false); const [remoteStream, setRemoteStream] = useState(null); @@ -60,6 +62,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) const [relaySnapshot, setRelaySnapshot] = useState(null); const [playbackBlocked, setPlaybackBlocked] = useState(false); const [muted, setMuted] = useState(false); + const [showConnectionDetails, setShowConnectionDetails] = useState(false); + + const qualityLimitation = useMemo( + () => + qualityLimitationSummary( + [peerSnapshot, relaySnapshot].filter( + (snapshot): snapshot is PeerSnapshot => snapshot !== null, + ), + ), + [peerSnapshot, relaySnapshot], + ); const videoRef = useRef(null); const peerRef = useRef(null); @@ -296,11 +309,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) if (previousParentId !== nextAssignment.parentPeerId) { clearUpstreamState(); - setStatusText( - nextAssignment.parentPeerId - ? "正在切换媒体来源" - : "等待可用的媒体来源", - ); + setStatusText("正在恢复连接"); } ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null); } @@ -344,7 +353,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) snapshot.connectionState === "failed" ) { clearPeerState(); - setStatusText("等待分享者再次开始"); + setStatusText("等待开始分享"); return; } setPeerSnapshot(snapshot); @@ -354,7 +363,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) snapshot.connectionState === "failed" || snapshot.connectionState === "disconnected" ) { - setStatusText("正在恢复媒体连接"); + setStatusText("正在恢复连接"); } } }, @@ -411,9 +420,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) ) { clearPeerState(); } - setStatusText( - message.hostOnline ? "等待分享画面" : "等待分享者开始分享", - ); + setStatusText(message.hostOnline ? "正在连接" : "等待开始分享"); const peer = peerRef.current; peer?.updateIceConfig(message.iceConfig); viewerRelay?.updateIceConfig(message.iceConfig); @@ -492,7 +499,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) } const peer = ensurePeer(); if (!peer) { - setStatusText("尚未收到可用的 ICE 配置"); + setStatusText("正在连接"); return; } await peer.acceptSignal(message.fromPeerId, message.payload); @@ -519,9 +526,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) currentHostOnline = message.online; setHostOnline(message.online); if (!message.online && !peerRef.current?.isConnected()) { - setStatusText("等待分享者开始分享"); + setStatusText("等待开始分享"); } else if (message.online && !peerRef.current?.isConnected()) { - setStatusText("等待分享画面"); + setStatusText("正在连接"); } return; } @@ -530,20 +537,20 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) clearViewerSfuRoute(); clearPeerState(); setHostOnline(false); - setStatusText("分享已停止,等待分享者再次开始"); + setStatusText("等待开始分享"); return; } if (message.type === "room-closed") { clearViewerSfuRoute(); clearPeerState(); - setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束"); + setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭"); signal.stop(); return; } if (message.type === "error") { if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) { clearPeerState(); - setStatusText("等待分享者再次开始"); + setStatusText("等待开始分享"); return; } if ( @@ -633,15 +640,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) function retryConnection(): void { if (!peerRef.current?.requestRecovery()) { - setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享"); + setStatusText(hostOnline ? "正在连接" : "等待开始分享"); } else { - setStatusText("正在恢复媒体连接"); + setStatusText("正在恢复连接"); } } return (
- } /> + + ) : null + } + />
@@ -650,9 +663,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)

房间 {roomId}

- {forceRelay && 强制中继} + {showConnectionDetails && forceRelay && ( + 强制中继 + )} - + {showConnectionDetails && ( + + )}
@@ -719,7 +736,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) - {!relayAvailable && + + + {showConnectionDetails && + !relayAvailable && signalStatus === "connected" && (hostOnline || peerSnapshot) && ( TURN 未配置,严格网络可能无法连接 @@ -729,13 +752,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps) {peerSnapshot.error} )} - {peerSnapshot && ( + {relaySnapshot?.error && ( +
+ {relaySnapshot.error} +
+ )} + {qualityLimitation && ( + {qualityLimitation} + )} + {showConnectionDetails && peerSnapshot && (

连接数据

)} - {relaySnapshot && ( + {showConnectionDetails && relaySnapshot && (

转发数据

- {relaySnapshot.error && ( -

{relaySnapshot.error}

- )} - {relaySnapshot.qualityWarning && ( -

{relaySnapshot.qualityWarning}

- )}
)} diff --git a/src/client/styles.css b/src/client/styles.css index 22c095c..9ec7048 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -254,6 +254,70 @@ h3 { background: rgba(5, 7, 6, 0.72); } +.stage-entry { + gap: 13px; + width: 100%; +} + +.stage-status { + color: var(--muted); +} + +.entry-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); + width: min(520px, calc(100% - 20px)); + gap: 8px; + align-items: stretch; +} + +.entry-action { + display: inline-flex; + min-width: 0; + min-height: 44px; + gap: 7px; + align-items: center; + justify-content: center; + padding: 0 9px; + border: 1px solid #326645; + border-radius: 6px; + color: var(--text); + background: #14271b; + font-size: 13px; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.entry-action svg { + flex: 0 0 auto; + color: var(--green); +} + +.entry-action:hover { + border-color: var(--green-strong); + background: var(--green-soft); +} + +.entry-divider { + display: grid; + grid-template-rows: minmax(3px, 1fr) auto minmax(3px, 1fr); + gap: 3px; + justify-items: center; + align-items: center; + color: var(--faint); + font-size: 11px; +} + +.entry-divider::before, +.entry-divider::after { + width: 1px; + height: 100%; + background: var(--border-strong); + content: ""; +} + .capture-strip { display: flex; min-height: 34px; @@ -293,10 +357,6 @@ h3 { } .setup-controls { - display: grid; - grid-template-columns: minmax(310px, 1fr) auto; - gap: 18px; - align-items: end; margin-top: 22px; } @@ -375,21 +435,31 @@ h3 { justify-self: start; } -.join-room-link { +.connection-details-toggle { display: inline-flex; - grid-column: 1 / -1; - width: fit-content; - gap: 7px; + min-height: 44px; + max-width: 100%; + gap: 9px; align-items: center; + margin-top: 8px; + padding: 0 2px; color: var(--muted); font-size: 13px; - text-decoration: none; + cursor: pointer; } -.join-room-link:hover { +.connection-details-toggle:hover { color: var(--text); } +.connection-details-toggle input { + width: 18px; + height: 18px; + flex: 0 0 18px; + margin: 0; + accent-color: var(--green-strong); +} + .control-group { min-width: 0; margin: 0; @@ -724,13 +794,6 @@ h3 { line-height: 1.4; } -.inline-warning { - margin-top: 10px; - color: var(--amber); - font-size: 12px; - line-height: 1.45; -} - .viewer-workspace { width: min(1380px, 100%); margin: 0 auto; @@ -817,10 +880,6 @@ h3 { flex-wrap: wrap; } - .setup-controls { - grid-template-columns: minmax(0, 1fr) auto; - } - .advanced-quality-grid { grid-template-columns: 1fr; } @@ -828,11 +887,6 @@ h3 { .quality-priority { grid-column: auto; } - - .start-button { - grid-column: auto; - } - .viewer-stats .stats-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -859,15 +913,6 @@ h3 { border-left: 0; } - .setup-controls { - grid-template-columns: 1fr; - gap: 14px; - } - - .start-button { - grid-column: auto; - } - .remote-stage { max-height: none; } diff --git a/tests/connection-details.test.ts b/tests/connection-details.test.ts new file mode 100644 index 0000000..260f5dd --- /dev/null +++ b/tests/connection-details.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { qualityLimitationSummary } from "../src/client/components/connection-details.ts"; +import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts"; + +function snapshot(qualityWarning: string | null): PeerSnapshot { + return { + peerId: "viewer-1", + connectionId: "connection-1", + connectionState: "connected", + iceConnectionState: "connected", + metrics: { ...EMPTY_METRICS, qualityLimitationReason: "bandwidth" }, + error: null, + qualityWarning, + }; +} + +describe("progressive connection details", () => { + it("keeps one debounced quality warning outside the details panel", () => { + expect( + qualityLimitationSummary([ + snapshot(null), + snapshot("持续受带宽限制,浏览器正在降低画面质量"), + snapshot("持续受编码性能限制,浏览器正在降低画面质量"), + ]), + ).toBe("持续受带宽限制,浏览器正在降低画面质量"); + expect(qualityLimitationSummary([snapshot(null)])).toBeNull(); + }); +});