Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

225 lines
8.9 KiB
Python

"""Tests for cross-process locking in :func:`migrate_to_profiles`.
The migration moves legacy flat-layout files into ``profiles/default/`` and
must run as a single writer: two ``notebooklm`` CLI invocations starting
under a fresh home (e.g., container start-up race) would otherwise both
copy + delete, leaving partial state visible to either.
Critical invariants exercised here:
* Two concurrent threads → exactly one copy is performed; the second
observes the marker inside the lock and no-ops.
* Pre-existing partial state (a legacy source dir removed mid-migration)
does not crash the loser of the race.
* The lock file is intentionally left on disk after migration completes
(``filelock`` reuses lock files across runs — see ``_atomic_io.py``).
* A held lock surfaces :class:`MigrationLockTimeoutError` rather than
blocking forever.
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from filelock import FileLock
import notebooklm.migration as migration_module
from notebooklm.migration import (
_MIGRATION_LOCK,
_MIGRATION_MARKER,
MigrationLockTimeoutError,
migrate_to_profiles,
)
from notebooklm.paths import _reset_config_cache, set_active_profile
@pytest.fixture(autouse=True)
def _reset_profile_state():
"""Reset module-level profile state between tests."""
set_active_profile(None)
_reset_config_cache()
yield
set_active_profile(None)
_reset_config_cache()
def _seed_legacy_layout(home: Path) -> None:
"""Create a minimal legacy flat layout under ``home``."""
(home / "storage_state.json").write_text('{"cookies":[]}', encoding="utf-8")
(home / "context.json").write_text('{"notebook_id":"nb1"}', encoding="utf-8")
(home / "browser_profile").mkdir()
(home / "browser_profile" / "data").write_text("chrome data", encoding="utf-8")
def test_concurrent_threads_only_one_migrates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Two threads race on a fresh home — only one performs the copy.
The loser must acquire the lock AFTER the winner has written the
marker, re-check the marker, and return ``False`` as a no-op. Without
the lock, both threads would copy + delete, racing on the same legacy
files (and the second ``unlink`` would have crashed pre-fix with
``FileNotFoundError``).
Note: thread-based contention exercises ``filelock``'s internal
``threading.Lock`` rather than the OS-level ``fcntl``/``LockFileEx``
layer that production cross-process races would hit. The invariant
under test — single-writer migration body — is identical in both
cases; we accept the unit-test tradeoff to avoid subprocess overhead.
"""
_seed_legacy_layout(tmp_path)
# Set NOTEBOOKLM_HOME once outside the workers. Using
# ``patch.dict(os.environ, ..., clear=True)`` inside concurrent
# threads races: each thread snapshots the *current* (already
# cleared) env as its "original", and the last thread to exit
# restores a near-empty env — wiping USERPROFILE on Windows and
# breaking later tests that call ``Path.home()``.
monkeypatch.setenv("NOTEBOOKLM_HOME", str(tmp_path))
results: list[bool] = []
errors: list[BaseException] = []
barrier = threading.Barrier(2)
def worker() -> None:
try:
barrier.wait()
results.append(migrate_to_profiles())
except BaseException as exc: # noqa: BLE001 - capture all failures
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"unexpected exception(s) in worker(s): {errors}"
assert sorted(results) == [False, True], (
f"exactly one thread should migrate; got results={results}"
)
default_dir = tmp_path / "profiles" / "default"
assert (default_dir / _MIGRATION_MARKER).exists()
assert (default_dir / "storage_state.json").exists()
assert (default_dir / "context.json").exists()
assert (default_dir / "browser_profile" / "data").exists()
# Originals removed exactly once — no leftover legacy files.
assert not (tmp_path / "storage_state.json").exists()
assert not (tmp_path / "context.json").exists()
assert not (tmp_path / "browser_profile").exists()
def test_loser_tolerates_partial_state_mid_race(tmp_path: Path) -> None:
"""Pre-existing partial state (legacy dir removed) does not crash.
Simulates the window where the winner copied + unlinked the legacy
files but the loser entered before the marker was written. The loser
must not crash on the missing source paths.
"""
_seed_legacy_layout(tmp_path)
# Simulate winner having copied + removed legacy files BUT not yet
# written the marker (which is normally the last step).
default_dir = tmp_path / "profiles" / "default"
default_dir.mkdir(parents=True)
(default_dir / "storage_state.json").write_text('{"cookies":[]}', encoding="utf-8")
(default_dir / "context.json").write_text('{"notebook_id":"nb1"}', encoding="utf-8")
(default_dir / "browser_profile").mkdir()
(default_dir / "browser_profile" / "data").write_text("chrome data", encoding="utf-8")
# Remove the legacy SOURCES (winner already unlinked them) but skip
# writing the marker (winner crashed before marker write).
(tmp_path / "storage_state.json").unlink()
(tmp_path / "context.json").unlink()
import shutil as _shutil
_shutil.rmtree(tmp_path / "browser_profile")
# Loser enters with NO legacy files left and NO marker yet — exercises
# the "already migrated" no-legacy branch.
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
# Must not raise on already-removed sources.
result = migrate_to_profiles()
assert result is False
# Profile copies are intact regardless of which branch the loser took.
assert (default_dir / "storage_state.json").exists()
assert (default_dir / "context.json").exists()
def test_lock_does_not_block_subsequent_runs(tmp_path: Path) -> None:
"""The migration lock does not interfere with subsequent invocations.
``filelock`` on POSIX unlinks the lock file on release (3.25+ behavior),
while on Windows the file may persist — either outcome is acceptable
because the lock is recreated lazily on next acquisition. What matters
is that subsequent ``migrate_to_profiles()`` calls are clean no-ops
and do not leave the home dir in a broken state.
"""
_seed_legacy_layout(tmp_path)
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
assert migrate_to_profiles() is True
# Subsequent invocations are clean no-ops — proves the lock state from
# the first run did not poison the second acquisition.
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
assert migrate_to_profiles() is False
assert migrate_to_profiles() is False
# Marker still present — no accidental wipe.
assert (tmp_path / "profiles" / "default" / _MIGRATION_MARKER).exists()
def test_held_lock_surfaces_timeout_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A held lock causes :class:`MigrationLockTimeoutError` to surface.
The test pre-acquires the lock for the duration of the call under
test, then uses filelock's non-blocking timeout path so the test runs quickly. The
expected outcome is the new wrapper exception, NOT a raw
:class:`filelock.Timeout` (callers should see a domain-specific
error).
"""
# Pre-create the home so the lock file's parent directory exists.
tmp_path.mkdir(exist_ok=True)
_seed_legacy_layout(tmp_path)
lock_path = tmp_path / _MIGRATION_LOCK
holder = FileLock(str(lock_path))
holder.acquire()
# Non-blocking acquire exercises the same wrapper branch without a real wait.
monkeypatch.setattr(migration_module, "_MIGRATION_LOCK_TIMEOUT", 0.0, raising=True)
try:
with (
patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True),
pytest.raises(MigrationLockTimeoutError) as exc_info,
):
migrate_to_profiles()
finally:
holder.release()
# Wrapper preserves the underlying filelock Timeout as the cause.
assert exc_info.value.__cause__ is not None
assert "migration lock" in str(exc_info.value).lower()
# Once released, the call succeeds — proves the lock was the cause.
with patch.dict(os.environ, {"NOTEBOOKLM_HOME": str(tmp_path)}, clear=True):
assert migrate_to_profiles() is True
def test_migration_lock_timeout_error_is_runtime_error() -> None:
"""The exception class is a ``RuntimeError`` subclass.
Lets callers catch ``RuntimeError`` and still hit our migration-
specific failure (matches the existing ``RuntimeError`` pattern in
``auth.py``).
"""
assert issubclass(MigrationLockTimeoutError, RuntimeError)