""" The Windows installer (W4): a single per-user NSIS package carrying the Electron client and the frozen node daemon. Like test_packaging_units.py this reads the config rather than building anything — there is no electron-builder or PyInstaller run here. Weak evidence, and the right kind for the defects it guards against: a per-machine flag that would make the installer demand admin, a build step wired to the wrong file, the 150 MB node-runtime artifact slipping into git, the autostart seam between the NSIS uninstaller and meshbay_node.platform drifting apart. """ import json import re from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[3] CLIENT = ROOT / "packages" / "meshbay-client" PKG_JSON = CLIENT / "package.json" WIN = ROOT / "packaging" / "win" NSH = CLIENT / "build" / "installer.nsh" pytestmark = pytest.mark.skipif( not PKG_JSON.exists() or not WIN.exists(), reason="Windows packaging not present") def _pkg() -> dict: return json.loads(PKG_JSON.read_text(encoding="utf-8")) # ── electron-builder: Windows target ──────────────────────────────────────── def test_the_windows_target_is_nsis_with_the_committed_icon(): win = _pkg()["build"]["win"] assert win["target"] == "nsis" icon = CLIENT / win["icon"] assert icon.suffix == ".ico" and icon.exists(), f"{icon} is missing" def test_the_installer_is_per_user_and_never_asks_for_admin(): """ A logon-triggered scheduled task needs elevation (that is why W3 uses the Startup folder), and the whole desktop design is no-admin. perMachine or allowElevation here would undo that at install time. """ nsis = _pkg()["build"]["nsis"] assert nsis["oneClick"] is False assert nsis["perMachine"] is False assert nsis["allowElevation"] is False assert nsis["allowToChangeInstallationDirectory"] is True def test_the_node_runtime_is_carried_as_an_extraresource(): """ PyInstaller output lands in packages/meshbay-client/node-runtime/ and rides into the package under resources/node-runtime/. src/main.js:findNodeBinary resolves exactly that path (process.resourcesPath / node-runtime / meshbay-node.exe), so the two names must agree. """ extra = _pkg()["build"]["win"]["extraResources"] entry = next((e for e in extra if e.get("to") == "node-runtime"), None) assert entry, "no extraResources entry mapping to node-runtime" assert entry["from"] == "node-runtime" main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") assert "'node-runtime', 'meshbay-node.exe'" in main_js, ( "findNodeBinary no longer looks for the bundled daemon where " "extraResources puts it") def test_find_node_binary_strips_stray_cr_from_multiline_where_output(): """ where.exe/which can list more than one match on PATH, and each line keeps its own trailing \\r on Windows. `stdout.trim().split('\\n')[0]` only strips the ends of the *whole* string, so with 2+ matches a stray \\r stayed glued to the end of the first line -- which then landed inside the quoted path written into the Startup .vbs and broke VBScript's parser with "Unterminated string constant" the next time Windows ran it at sign-in. Reproduced live 2026-09-05 (this user's own machine has both a dev venv and an installed build on PATH) and fixed by splitting on \\r?\\n and trimming each candidate line individually. """ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") assert "stdout.split(/\\r?\\n/)" in main_js, ( "findNodeBinary must split where.exe/which output on \\r?\\n and " "trim each line, not a single stdout.trim() over the whole blob") assert "stdout.trim().split('\\n')[0]" not in main_js def test_firewall_helper_is_carried_as_an_extraresource(): """packaging/win/firewall.ps1 must ride into resources/, at the fixed path installer.nsh invokes it from ($INSTDIR\\resources\\firewall.ps1).""" extra = _pkg()["build"]["win"]["extraResources"] entry = next((e for e in extra if e.get("to") == "firewall.ps1"), None) assert entry, "no extraResources entry mapping to firewall.ps1" assert entry["from"].endswith("packaging/win/firewall.ps1") assert (ROOT / "packaging" / "win" / "firewall.ps1").exists() def test_dist_win_delegates_to_the_build_script(): """`dist` (Linux) delegates to build-client.sh; `dist:win` is its counterpart and must not be a second inline electron-builder invocation.""" scripts = _pkg()["scripts"] assert "dist:win" in scripts assert "build-win.ps1" in scripts["dist:win"] # `dist` stays Linux-only and unchanged (test_desktop_shell.py guards it too). assert "win" not in scripts["dist"].lower() # ── the build scripts exist and point at real files ──────────────────────── @pytest.mark.parametrize("name", [ "build-win.ps1", "build-node-runtime.ps1", "meshbay-node.spec", "node-entry.py", "README.md", ]) def test_packaging_win_ships_its_scripts(name): assert (WIN / name).exists(), f"packaging/win/{name} is missing" def test_the_pyinstaller_entry_point_is_the_daemon_main(): src = (WIN / "node-entry.py").read_text(encoding="utf-8") assert "from meshbay_node.daemon import main" in src assert "main()" in src def test_the_spec_pulls_in_the_awkward_dependencies_whole(): """ The C/Rust-extension and dynamic-import packages are the ones PyInstaller's static pass drops. If someone trims collect_all to shrink the build, the frozen daemon fails at runtime, not at build time. """ spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") for pkg in ("aiortc", "av", "aioquic", "pydantic_core", "uvicorn", "watchdog", "guessit", "blake3", "meshbay_node", "meshbay_common"): assert re.search(rf'["\']{re.escape(pkg)}["\']', spec), ( f"{pkg} dropped from the PyInstaller spec's collect list") def test_the_frozen_exe_carries_a_version_resource(): """ Without it the Windows Firewall prompt, Task Manager and Properties show a bare "meshbay-node". The version is read from the installed package so it tracks pyproject rather than being a second copy to update. """ spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") assert "VSVersionInfo" in spec assert "version=_version_info" in spec, "EXE() is not given the version resource" assert 'StringStruct("ProductName", "MeshBay Node")' in spec assert '_pkg_version("meshbay-node")' in spec, "version is hardcoded, not read from the package" # ── the artifact never gets committed ────────────────────────────────────── def test_the_node_runtime_output_is_gitignored(): """It is ~150 MB of frozen Python. The `ui/` fork guard in test_desktop_shell.py exists for the same reason.""" gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") assert "packages/meshbay-client/node-runtime/" in gitignore # ── the NSIS ↔ platform.py autostart seam ───────────────────────────────── def test_the_uninstaller_clears_the_autostart_launcher(): """ W3's `meshbay-node autostart install` drops a .vbs in the Startup folder (meshbay_node.platform._startup_vbs). After an uninstall it would point wscript at a deleted binary every sign-in, so customUnInstall must delete it — and at the path platform.py actually uses. """ from meshbay_node import platform as plat nsh = NSH.read_text(encoding="utf-8") assert "!macro customUnInstall" in nsh assert "taskkill /IM meshbay-node.exe /F" in nsh # The tail platform.py builds, made NSIS-relative ($APPDATA == %APPDATA%). tail = plat._startup_vbs() parts = tail.parts i = parts.index("Microsoft") rel = "\\".join(parts[i:]) # Microsoft\...\Startup\MeshBay Node.vbs assert rel in nsh, ( f"customUnInstall does not delete {rel!r} — the W3 autostart path " "changed and installer.nsh was not updated") def test_customInstall_stops_a_running_daemon_before_overwriting_it(): nsh = NSH.read_text(encoding="utf-8") body = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] assert "taskkill /IM meshbay-node.exe /F" in body # ── the one-time elevated firewall step ───────────────────────────────────── def _macro_body(nsh: str, name: str) -> str: return nsh.split(f"!macro {name}", 1)[1].split("!macroend", 1)[0] def test_the_installer_offers_a_service_mode_choice_with_one_elevation(): """ Adding a firewall rule or a boot-time Scheduled Task both need admin; the install itself never elevates (build.nsis allowElevation:false). So this must be opt-in (a Yes/No the user can decline) and skipped entirely in a silent install — an unattended `/S` install must never pop a UAC prompt on its own. Choosing service mode must fold the Scheduled Task AND the firewall rules into ONE elevation (service-mode.ps1), never two. """ nsh = NSH.read_text(encoding="utf-8") install = _macro_body(nsh, "customInstall") assert "${IfNot} ${Silent}" in install, ( "the mode choice is not guarded against silent installs") assert install.count("MessageBox MB_YESNO") == 2, ( "expected exactly two questions: service-mode-or-not, then (only in " "the per-user branch) the firewall-only question") assert 'ExecShellWait "runas"' in install # Service mode: one elevated call for both jobs, not one each. assert 'service-mode.ps1" -Action install' in install assert 'firewall.ps1" add' not in install.split("mb_peruser_mode:", 1)[0], ( "service mode must not ALSO separately elevate for firewall.ps1 — " "service-mode.ps1 already does that in the same elevation") # Per-user mode (declined the service question) keeps today's separate, # still-opt-in firewall step. peruser_branch = install.split("mb_peruser_mode:", 1)[1] assert 'firewall.ps1" add' in peruser_branch def test_reinstalling_with_everything_already_in_place_asks_nothing(): """ Get-NetFirewallRule needs no admin, only New/Remove do — so customInstall checks the firewall rules first, unelevated, and only reaches the mode question (and therefore a possible UAC prompt) when something is actually missing. Without this, running setup a second time — an upgrade, a repair install — would re-ask the question (and, in service mode, re-trigger UAC) even though nothing needs to change. Checking the firewall rules alone is enough: service-mode.ps1 always sets up both together, so if the rules are there, so is everything else that was chosen last time. """ nsh = NSH.read_text(encoding="utf-8") install = _macro_body(nsh, "customInstall") check_line = 'firewall.ps1" check' assert check_line in install mode_question = "Run MeshBay Node as a background service?" # The check must run, and be evaluated, before the mode question — not after. assert install.index(check_line) < install.index(mode_question) assert "Pop $0" in install and "${If} $0 == 0" in install assert "Goto mb_mode_done" in install def test_the_uninstaller_offers_to_remove_everything_privileged_default_no(): """ Opt-in on the way out too, and defaulting to No: a stale allow-rule or Scheduled Task is inert, so this should not nag. One elevation removes both, unconditionally — service-mode.ps1's own remove actions are each no-ops when there is nothing to remove, so this is safe to run whether or not service mode was ever chosen. """ nsh = NSH.read_text(encoding="utf-8") uninstall = _macro_body(nsh, "customUnInstall") assert "${IfNot} ${Silent}" in uninstall assert "/SD IDNO" in uninstall, "the uninstall prompt should default to No" assert 'service-mode.ps1" -Action remove' in uninstall assert 'ExecShellWait "runas"' in uninstall # ── service mode itself (packaging/win/service.ps1, service-mode.ps1) ────── def test_service_ps1_and_service_mode_ps1_are_extraresources(): for name in ("service.ps1", "service-mode.ps1"): entry = next( (e for e in _pkg()["build"]["win"]["extraResources"] if e.get("to") == name), None) assert entry, f"no extraResources entry mapping to {name}" assert entry["from"].endswith(f"packaging/win/{name}") assert (WIN / name).exists() def test_service_install_uses_s4u_not_a_stored_password(): """ schtasks /create ... /ru /rp "" with no /it registers an S4U logon: no password stored anywhere, and — unlike LocalSystem/NetworkService — it loads this account's own profile, so %LOCALAPPDATA%\\meshbay\\ needs no relocation. Losing the empty /rp "" (e.g. "fixing" it into a real prompt for a password) would either store a secret or silently stop working. """ src = (WIN / "service.ps1").read_text(encoding="utf-8") # The prose above the actual command is allowed to say "/it" while # explaining why it is absent (the same "read the comment, not the # directive" trap CLAUDE.md already tracks) -- so check the real # invocation line, not the whole file. create_line = next( (line for line in src.splitlines() if line.strip().startswith("& schtasks") and "/create" in line), None) assert create_line, "no schtasks /create invocation found" tokens = create_line.split() assert "/sc" in tokens and tokens[tokens.index("/sc") + 1] == "onstart", ( "must trigger at boot, not at sign-in (/sc onlogon)") assert "/ru" in tokens assert "/rp" in tokens and tokens[tokens.index("/rp") + 1] == '""', ( "must pass an empty run-as password (S4U)") assert "/it" not in tokens, "an interactive-token task would need the user signed in" assert "Get-Credential" not in src, "no password should ever be prompted for" def test_service_task_name_is_the_same_everywhere(): """One name, three places: meshbay_node.platform.TASK_NAME (the CLI), service.ps1 (the installer), and main.js's WIN_SERVICE_TASK (the client driving Start/Stop/Restart). A mismatch means the client manages a task that does not exist, or vice versa.""" from meshbay_node import platform as plat service_ps1 = (WIN / "service.ps1").read_text(encoding="utf-8") main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") assert f'$TASK_NAME = "{plat.TASK_NAME}"' in service_ps1 assert f"WIN_SERVICE_TASK = '{plat.TASK_NAME}'" in main_js def test_service_status_reports_state_without_admin(): """status/run/end must not need elevation once the task exists (only install/remove do) — that is what lets the Node page drive it with no further UAC prompts. Nothing in those branches should invoke as an elevated call; only the two macros in installer.nsh use "runas".""" src = (WIN / "service.ps1").read_text(encoding="utf-8") assert "runas" not in src.lower(), ( "service.ps1 itself must never self-elevate — installer.nsh already " "runs the whole script elevated for install/remove, and the client " "calls status/run/end directly, unelevated") HUB_STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" def test_service_mode_toggle_elevates_the_same_script_the_installer_runs(): """ The installer's own mode question is effectively one-shot (it skips itself the moment the firewall rules exist for any reason, and per-user mode sets those up on its own with no Scheduled Task involved) -- so declining once, or the rules existing for any other reason, is a dead end through setup alone. The Node page's toggle is the other door in (and out), and it must drive service-mode.ps1 -- the exact script installer.nsh runs -- so the two paths can never disagree about what "service mode" means. One elevation (-Verb RunAs), no stored password. """ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") assert "winElevateServiceMode" in main_js fn = main_js.split("function winElevateServiceMode", 1)[1] fn = fn[:fn.index("\n }\n")] assert "service-mode.ps1" in fn assert "-Verb RunAs" in fn or "'-Verb', 'RunAs'" in fn or "-Verb', 'RunAs'" in fn assert "Get-Credential" not in fn handler = main_js.split("ipcMain.handle('node:service-mode'", 1)[1] handler = handler[:handler.index("ipcMain.handle(")] assert "winElevateServiceMode" in handler assert "'install'" in handler or '"install"' in handler assert "'remove'" in handler or '"remove"' in handler def test_node_page_service_mode_toggle_is_wired_end_to_end(): """preload.js -> platform.js -> node-page.js, the same three-layer shape the existing autostart toggle uses. A break anywhere in this chain means the checkbox renders but does nothing, or never renders at all.""" preload = (CLIENT / "src" / "preload.js").read_text(encoding="utf-8") assert "serviceMode:" in preload assert "'node:service-mode'" in preload platform_js = (HUB_STATIC / "platform.js").read_text(encoding="utf-8") assert "serviceMode:" in platform_js assert "bridge.node.serviceMode('install')" in platform_js assert "bridge.node.serviceMode('remove')" in platform_js node_page = (HUB_STATIC / "node-page.js").read_text(encoding="utf-8") assert "platform.node.serviceMode.available" in node_page assert "platform.node.serviceMode.install()" in node_page assert "platform.node.serviceMode.remove()" in node_page # Checked state must reflect the CURRENT mode, not a separate flag -- # otherwise the toggle and the status panel above it could disagree. assert "info.mode === 'service'" in node_page def test_the_help_smoke_test_joins_multiline_output_before_matching(): """ `& exe --help 2>&1` is an ARRAY once the output wraps past one line, which it now does with `autostart`/`service` in the verb list. `$array -notmatch X` is a FILTER, not a boolean test -- it returns the *non-matching* elements, and any non-empty array is truthy in `if()` regardless of what is in it. Almost every help line lacks the literal string "meshbay-node", so an unjoined check throws unconditionally the moment --help exceeds one line: found when this specific verb list finally grew past that point, which is exactly the kind of one-off silent breakage a passing build yesterday gives no warning of today. """ src = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") body = src.split("smoke test: meshbay-node --help", 1)[1] body = body[:800] assert '-join "`n"' in body, ( "the captured --help output must be joined to a single string before " "any -match/-notmatch check, or a multi-line result silently always " "fails the smoke test") # And the join has to happen at capture time, not on some other variable. assert '(& $exe --help 2>&1) -join' in body def test_main_js_drives_the_service_task_for_all_three_actions(): """The hard requirement: Start/Stop/Restart from the Node page must control the Scheduled Task when service mode is active, not just spawn a detached process that has nothing to do with it.""" main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") for handler in ("node:service-stop", "node:service-restart", "node:start"): body = main_js.split(f"ipcMain.handle('{handler}'", 1)[1] body = body[:body.index("ipcMain.handle(")] assert "winServiceTaskStatus" in body, f"{handler} never checks for the service task" def test_node_start_provisions_before_it_ever_touches_the_service_task(): """ On a fresh install with service mode chosen, the Scheduled Task exists before anything is provisioned (node.toml is written by the wizard, not by the installer). node:start must call provisionNode() first and only then ask about the service task / spawn -- reversed, the very first "start" from the wizard would run (or query) an unconfigured daemon instead of the one it just told the caller to expect. A bare unprovisioned run is independently proven harmless (test_a_bare_invocation_with_no_config_yet_exits_cleanly in test_cli_dispatch.py), so this is about correctness of *this* call actually starting the daemon the wizard just configured, not about safety if the order were reversed. """ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") body = main_js.split("ipcMain.handle('node:start'", 1)[1] body = body[:body.index("ipcMain.handle(")] provision_at = body.index("provisionNode(") service_check_at = body.index("winServiceTaskStatus") assert provision_at < service_check_at, ( "node:start checks the service task before provisioning — a fresh " "install's first Start would run/query the daemon before node.toml " "exists for it to read") def test_firewall_ps1_targets_both_executables_and_is_idempotent(): """One script, both rules — so installer.nsh only ever has to name it once on the way in and once on the way out.""" src = (ROOT / "packaging" / "win" / "firewall.ps1").read_text(encoding="utf-8") assert "MeshBay.exe" in src assert "node-runtime" in src and "meshbay-node.exe" in src # Remove-then-add: a re-run (reinstall, or install after a manual add) # must not leave duplicate rules. assert src.index("Remove-NetFirewallRule") < src.index("New-NetFirewallRule") def test_firewall_ps1_also_covers_lan_casting(): """ The WebRTC rules only reach MeshBay.exe / meshbay-node.exe; the cast HTTP relay (src/cast-relay.js, fixed TCP 19550-19553) and mDNS device discovery (src/cast-chromecast.js, bonjour-service, UDP 5353) are a separate surface on the client alone, and need their own ports and protocols. Ports here must agree with cast-relay.js's own constants and with the Linux definitions in packaging/firewall/*/meshbay-cast.xml — three descriptions of one port range that must not drift apart. """ src = (ROOT / "packaging" / "win" / "firewall.ps1").read_text(encoding="utf-8") assert "19550-19553" in src, "cast TCP range missing or does not match cast-relay.js" assert '"5353"' in src, "mDNS discovery port (UDP 5353) missing" relay = (CLIENT / "src" / "cast-relay.js").read_text(encoding="utf-8") assert "PORT_BASE = 19550" in relay and "PORT_COUNT = 4" in relay, ( "cast-relay.js's port range changed — update firewall.ps1 to match") firewalld = (ROOT / "packaging" / "firewall" / "firewalld" / "meshbay-cast.xml").read_text( encoding="utf-8") assert "19550-19553" in firewalld and "5353" in firewalld, ( "the Linux and Windows cast firewall definitions have drifted apart") def test_the_bundled_daemon_goes_on_the_user_path_and_comes_back_off(): """ The installer has no console entry point of its own; without this the operator has to `cd` into resources\\node-runtime\\ to run `meshbay-node`. The add uses stock WordFunc (no EnVar plugin — electron-builder's NSIS does not bundle it) and the same $INSTDIR-relative string on the way out. """ nsh = NSH.read_text(encoding="utf-8") assert "!insertmacro WordAdd" in nsh and "!insertmacro un.WordAdd" in nsh install = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] uninstall = nsh.split("!macro customUnInstall", 1)[1].split("!macroend", 1)[0] assert 'HKCU "Environment" "Path"' in install assert "${WordAdd}" in install and '"+${MB_NODE_BIN}"' in install assert "${un.WordAdd}" in uninstall and '"-${MB_NODE_BIN}"' in uninstall # New shells need the broadcast to notice. assert "WM_WININICHANGE" in install and "WM_WININICHANGE" in uninstall # PATH points at the real .exe dir, so `where meshbay-node` resolves to the # binary the client and the W3 launcher use too — not a shim. assert 'MB_NODE_BIN "$INSTDIR\\resources\\node-runtime"' in nsh # ── ffmpeg: bundled by default (fetch-ffmpeg.ps1) ─────────────────────────── def test_fetch_ffmpeg_and_its_license_notice_exist(): assert (WIN / "fetch-ffmpeg.ps1").exists() notice = WIN / "LICENSE-ffmpeg.txt" assert notice.exists() text = notice.read_text(encoding="utf-8") assert "GPL" in text assert "ffmpeg.org" in text or "FFmpeg/FFmpeg" in text, ( "the notice must point at where the corresponding source actually is") def test_ffmpeg_is_pinned_to_a_dated_release_not_the_moving_latest_alias(): """ BtbN repoints the "latest" release on every auto-build (their asset filenames even embed a fresh git-describe each time), so a URL built from that alias silently changes what a build fetches. The dated tag (autobuild-YYYY-MM-DD-HH-MM) is immutable once published -- that is what makes the pinned checksum mean anything. """ src = (WIN / "fetch-ffmpeg.ps1").read_text(encoding="utf-8") tag_match = re.search(r'\$FFMPEG_TAG\s*=\s*"([^"]+)"', src) assert tag_match, "no $FFMPEG_TAG pin found" assert re.match(r"autobuild-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}$", tag_match.group(1)), ( f"{tag_match.group(1)!r} is not a dated release tag") assert "/releases/latest/" not in src and "/releases/download/latest/" not in src sha_match = re.search(r'\$FFMPEG_SHA256\s*=\s*"([0-9a-f]+)"', src) assert sha_match, "no $FFMPEG_SHA256 pin found" assert len(sha_match.group(1)) == 64, "not a full sha256 hex digest" assert '$FFMPEG_URL' in src and '$FFMPEG_TAG' in src.split("$FFMPEG_URL", 1)[1][:200], ( "the download URL must be built from the pinned tag") def test_ffplay_is_excluded_from_the_bundle(): """The vendor zip carries an SDL2 player MeshBay never invokes (~17 MB); bundling it would be the collect_all-everything instinct applied where a fixed allowlist is right instead.""" src = (WIN / "fetch-ffmpeg.ps1").read_text(encoding="utf-8") keep_block = src.split("$KEEP_FILES", 1)[1].split(")", 1)[0] assert "ffplay" not in keep_block assert "ffmpeg.exe" in keep_block and "ffprobe.exe" in keep_block def test_ffmpeg_bundling_is_the_default_not_opt_in(): """ Video streaming needs a real H.264 encoder (libx264, GPL -- no LGPL-only ffmpeg build has one), and asking an end user to separately run `winget install ffmpeg` is not viable for a non-technical install (needs network + winget present at that moment, fails silently). So bundling must be the default, with an explicit opt-out for local iteration -- the reverse of the old -FfmpegDir opt-in this replaced. """ runtime_src = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") assert "SkipFfmpeg" in runtime_src assert "FfmpegDir" not in runtime_src, "the old opt-in mechanism should be gone, not parallel" assert "fetch-ffmpeg.ps1" in runtime_src win_src = (WIN / "build-win.ps1").read_text(encoding="utf-8") assert "SkipFfmpeg" in win_src assert "FfmpegDir" not in win_src