Skip to content

fix(tests): eliminate the two cross-suite unit test flakes - #448

Merged
sozercan merged 3 commits into
sozercan:mainfrom
tsibog:fix/cookie-archive-test-isolation
Aug 22, 2026
Merged

fix(tests): eliminate the two cross-suite unit test flakes#448
sozercan merged 3 commits into
sozercan:mainfrom
tsibog:fix/cookie-archive-test-isolation

Conversation

@tsibog

@tsibog tsibog commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes the two pre-existing flaky unit tests that have been failing macOS Unit Tests on unrelated PRs. Both are test-only issues; no production behaviour changes.

1. WebKitCookieRestoreTests — shared cookie archive (macos-26)

WebKitManager.clearAllData() calls CookieArchiveWriteQueue.shared.invalidateAndDelete() — a process-wide actor over KeychainCookieStorage. Seven test files call clearAllData(), and Swift Testing runs their suites concurrently, so one suite deletes the cookie archive another suite has just written. .serialized on the individual suites doesn't help: it only orders tests within a suite.

Repro on current main:

swift test --skip KasetUITests --filter WebKit     # fails 5/5
swift test --skip KasetUITests --filter WebKitCookieRestoreTests   # passes 3/3
✘ "Persisted archive replaces stale live authentication cookies"
  Expectation failed: (cookies.first { $0.name == "__Secure-3PAPISID" }?.value → nil) == "expected-session"

2. HistoryViewModelTests — refresh finishing before loadMore (macos-15)

loadMoreBlockedWhileRefreshRewindsHistoryCursor simulated an in-flight refresh with a 100ms getHistory delay, then polled every 25ms before calling loadMore(). On loaded runners the poll returned after refresh had already completed, so the !isRefreshingHistory guard no longer blocked loadMore(): 3 continuation calls instead of 1, with "Older" appended.

Replaced the delay with an explicit continuation gate on the mock, matching the existing shouldWaitForRemoveSongFromPlaylistResponse pattern, so refresh cannot finish before loadMore() is attempted.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • WebKitManager takes an injectable cookieArchiveQueue, defaulting to .shared — production behaviour is unchanged, still a single process-wide archive
  • makeTestInstance() gets its own CookieArchiveWriteQueue over a new CookieArchiveStorage.inMemory(), so parallel suites can no longer clear or overwrite each other's archive
  • The 30 CookieArchiveWriteQueue.shared call sites (all inside extension WebKitManager) now go through the injected queue
  • New regression test: clearing one manager's data leaves another manager's archive intact — verified to fail without the injection
  • Extracted AuthCookieOperationFence / LiveAuthCookieClearResult / LiveAuthCookieStoreClearer into WebKitManager+AuthCookieClearing.swift to keep WebKitManager.swift under the 900-line file_length limit
  • Gated the history refresh test on an explicit mock continuation instead of a wall-clock delay

CookieArchiveStorage.inMemory() returns .allowed from restoreDecision, which matches what the live storage already does under unit tests (CookieArchiveRestorePolicy.restoreDecision short-circuits on UITestConfig.isRunningUnitTests), so no test coverage is weakened.

Testing

  • swift test --skip KasetUITests — 6 consecutive full-suite runs, 0 failures
  • swift test --skip KasetUITests --filter WebKit — 5/5 green (was 5/5 red)
  • Cookie flake measured before/after: 8/8 full-suite runs red on main, 0/7 red with the fix
  • New regression test confirmed red without the fix, green with it
  • swiftlint --strict && swiftformat . clean

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat .
  • I have added tests that prove my fix works
  • New and existing unit tests pass locally
  • My changes generate no new warnings

Additional Notes

Production behaviour is unchanged — WebKitManager.shared still uses CookieArchiveWriteQueue.shared. The injection point exists only so tests stop sharing one global archive.

The HistoryViewModelTests fix is cherry-picked from #442, where it was authored as part of an unrelated feature branch. It's shared test infrastructure, so it's carried here to unblock every branch rather than only that one — #442 can drop its copy once this lands.

Unrelated flakes remain in ScrobblingCoordinator* and PlaylistDetailViewModelTests; those look like separate shared-state issues and aren't touched here.

tsibog added 2 commits August 16, 2026 11:14
…queue

WebKitManager.clearAllData() called CookieArchiveWriteQueue.shared
.invalidateAndDelete(), so any suite clearing a test manager wiped the
archive another suite had just written. Seven test files call clearAllData()
and Swift Testing runs their suites concurrently; .serialized only orders
tests within a suite, so WebKitCookieRestoreTests failed whenever it lost
the race (100% with --filter WebKit, green alone).

Inject the queue into WebKitManager (defaulting to .shared, so production
keeps a single archive) and give makeTestInstance() its own queue over new
in-memory storage. Adds a regression test that fails without the injection.

Extract AuthCookieOperationFence/LiveAuthCookieStoreClearer into their own
file to keep WebKitManager.swift under the 900-line lint limit.
loadMoreBlockedWhileRefreshRewindsHistoryCursor simulated an in-flight
refresh with a 100ms getHistory delay, then polled every 25ms before
calling loadMore. On loaded runners the poll returned after refresh had
already completed, so the !isRefreshingHistory guard no longer blocked
loadMore: 3 continuation calls instead of 1, "Older" appended.

Replace the delay with an explicit continuation gate on the mock, matching
the existing shouldWaitForRemoveSongFromPlaylistResponse pattern, so
refresh cannot finish before loadMore is attempted.
@tsibog tsibog changed the title fix(tests): isolate per-manager cookie archive from the process-wide queue fix(tests): eliminate the two cross-suite unit test flakes Aug 16, 2026
@tsibog

tsibog commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
senshinya added a commit to senshinya/kaset that referenced this pull request Aug 21, 2026
…elay

generationMismatchRollbackRestoresSnapshot simulated an in-flight continuation
with a 150 ms delay, waited for loadingState to reach .loadingMore, then
cancelled. On a loaded runner the response came back and appended "c" before
the cancellation landed, so the rollback restored a snapshot of three tracks:

  ✘ (viewModel.playlistDetail?.tracks.map(\.videoId) → ["a","b","c"]) == ["a","b"]

It blocked two release builds on 2026-08-21 and is a third flake beyond the two
that upstream sozercan#448 addresses.

The response now waits on an AsyncGate through the mock's existing
beforePlaylistContinuationReturn hook, so the test cancels while the request is
provably still in flight and releases it afterwards. What the test asserts is
unchanged — applyRemainingTracksResponse still discards the batch through its
own !Task.isCancelled guard — but the ordering is now a fact rather than a bet
on scheduling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@sozercan sozercan left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! lgtm

@sozercan
sozercan merged commit 83e3797 into sozercan:main Aug 22, 2026
8 checks passed
httperry added a commit to httperry/kaset that referenced this pull request Aug 22, 2026
httperry added a commit to httperry/kaset that referenced this pull request Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants