CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.
Unit:
- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
but normalizeVideoModerationContext derives shouldShow from
`original.blurThumbnail`/`trustedCount`, so the assertion ran against a
context whose early return had just deleted the blur it was checking. 4525c1c6
also made a lone trusted mute a non-blocking ranking signal (its sibling test
was renamed to say exactly that; this one was missed). Fixture now mirrors
what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
editors and fallback seeds, so a whitelist change cannot alter them. The
removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
isTrustedReporter (being allowed to publish is not a statement that your
reports carry weight). A whitelisted reporter now counts toward the report
total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
the torrent definitively fails -- playbackService documents the change and its
rollback. The old assertion did not merely fail, it HUNG: forced sources skip
the start timeout, so the now-reachable hosted attempt waited forever on a
<video> that never emits.
E2E:
- ui-modals: asserted exactly one `.bv-modal` per component page, but
profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
least one and drives the primary, which is what the test's own injected
querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
rejecting as undecodable. Uses a real pubkey for the success path and pins the
rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
tolerating "No playable source found" in its status check -- contradictory,
since a failed attempt calls resetVideoElement() and clears src. Headless CI
cannot decode the archive.org H.264 sample. Now records src attachments and
asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
plaintext entry and the reader falls back to the sole remaining encrypted
account -- so null no longer describes the security property. Asserts the
property directly: no plaintext key survives, in the entry or in raw storage.
Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.
All 416 unit files, all three CI shards, both DM suites and lint are green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
On mobile a bitvid share link opens with no user gesture, so the browser
refuses autoplay. bitvid handled that correctly -- it showed "Press play to
start the hosted video." and armed a no-stall watchdog to wait -- but the 3s
start timeout kept running underneath and fired anyway. For a video with both a
hosted URL and a magnet that meant:
Press play to start the hosted video.
Switching to WebTorrent...
The element was reset out from under the prompt the viewer was about to act on,
and the swarm (often seedless, the webseed being the very URL we just
abandoned) played nothing. Desktop usually hides this because an established
Media Engagement score lets autoplay through, so the same link plays there.
A blocked autoplay is not a stalled source. Suspend the start timeout while we
are waiting on a user gesture and let the attempt settle on its own -- it
resolves when they press play, or when a real media error or stall fires. The
guard is cleared on the `play` gesture so a genuine stall afterwards still
fails over normally.
Reproduced on an emulated phone against the reported video
(v.nostr.build/1eNLjw5el05Uc3KU.mp4, magnet with a webseed): before, statuses
end "Press play..." then "Switching to WebTorrent..." with the P2P path
entered; after, they end at "Press play..." and playback starts on tap. The new
tests fail 2/4 without the guard.
Note for future test authors: createSession() overwrites any
playbackStartTimeout passed in its options with the service's value, so a
per-session override is silently ignored -- set it on the PlaybackService
constructor or the test races the real 3s default and proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.
Vendor hls.js (npm run build:hls) and route HLS sources through it:
- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
returns "maybe" for every HLS type, so a native-first branch would hang.
Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
audio stream controller and real ladders demux audio into an EXT-X-MEDIA
group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
bare-<video> probe could not load it and on Chrome emitted no events at all,
burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
mid-startup: the bundle, master playlist, variant playlist, init segment and
first media segment are four-plus serial round trips.
Also fixes two bugs found while verifying this:
- A stale MediaError was reported as the current failure. Teardown clears `src`
and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
on the element; a later watchdog fallback rendered that as "Hosted playback
failed: source not supported or blocked." over a stream that was playing
fine. Only a MediaError produced by the current attempt is surfaced now.
Sources are also bound before the watchdogs are armed, so hls.js's own
attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
published with it as podcasts and dropped them from every video feed.
Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A bitvid (kind-30078) video with a perfectly playable hosted URL (verified:
archive.org 200 video/mp4 with byte ranges) failed with "No playable source
found." — the video element's src was never even set.
Root cause: the hosted-URL probe (fetch HEAD, mode:no-cors) runs BEFORE src is
assigned, inside the playback-start window — but URL_PROBE_TIMEOUT_MS (4s) is
LONGER than PLAYBACK_START_TIMEOUT (3s). A slow no-cors HEAD (archive.org, and
any host that doesn't answer HEAD fast) consumed the whole start budget, the
withTimeout wrapper aborted the attempt, and the URL was never loaded. A
`<video>` element plays cross-origin URLs WITHOUT CORS anyway, so the fetch
probe was both unnecessary and actively harmful here.
Fix:
- Skip the probe entirely when there's no magnet (URL-only) — nothing to fail
over to, so the video element loads the URL directly. This is the reported
case; verified live: the video now plays (readyState 4, currentTime advancing).
- An ambiguous/error probe no longer VETOES the URL (only a definitively-read
"bad" status deprioritizes it, and even then only when a magnet fallback
exists) — the video element + watchdogs are the real arbiter.
Tests: spec-corrected the always-probe assertion to a dual-source case + added
a URL-only skip-probe test (tests/services/playbackService.test.mjs). 17 pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
Editing left grids showing the pre-edit version until a manual reload. The edit
flow already re-ran loadVideos(), but the cache still held the OLD version: unlike
new publishes (#46's optimistic insert), the edit never ingested its signed
replaceable event locally, so the cache-first render stayed stale until relays
echoed the update back.
handleEditVideoSubmit now ingests the edited signed event via
ingestLocalVideoEvent — newest-wins replace in the active store + videos:updated —
so the grid shows the new title/thumbnail/description immediately, with the relay
echo confirming behind it.
Test: nostr-service suite +1 (edited event ingested, videos:updated + videos:edited
emitted, result passed through). Lint green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Switching accounts (e.g. NIP-07 → nsec) still showed the OLD account's direct
messages. The earlier switch fix reset only the profile modal's DM view, but the
DM data lives in the app-wide nostrService store (this.dmMessages), which the
messages pane reads via listDirectMessageConversationSummaries /
getDirectMessagesForConversation. Logout clears that store
(stopDirectMessageSubscription + clearDirectMessages); the switch path never did,
so the old messages persisted.
Fix: on an identity change, authSessionCoordinator.handleAuthLogin now stops the
old DM subscription and clears the in-memory DM store BEFORE the new account's
subscription starts — so the pane shows the new account's DMs (or empty→new),
never the old.
Uses a new clearDirectMessages({ keepSnapshot: true }) option: it wipes the
in-memory store but preserves each account's PERSISTED per-actor DM snapshot, so
switching back to an account stays fast (per-account DM cache). Logout still fully
clears (keepSnapshot defaults false).
Tests: tests/services/nostr-service.test.mjs — clearDirectMessages empties the
in-memory store + resets the active actor and emits cleared/updated, in both
keepSnapshot modes. Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Uploading to a Backblaze B2 (or any generic-S3) bucket failed with "S3 endpoint is
required." even though the connection tested fine.
Root cause: the two upload services have different prepareUpload contracts —
r2Service.prepareUpload(npub, { credentials }) vs s3UploadService.prepareUpload(settings).
MediaUploader calls both the r2 way, so for S3/B2 the npub STRING became the settings →
validateS3Connection saw no endpoint. Compounding it, a storageService.getConnection()
result keeps bucket/endpoint/region under `.meta`, which validateS3Connection reads at
the top level. (R2 worked because its service already uses the (npub, {credentials})
contract.)
Fix:
- s3UploadService.prepareUpload now accepts (npub, { credentials }) AND a direct settings
object (back-compat), flattening meta → top-level via flattenS3Settings.
- The storage Test now derives/passes the B2 public URL (else the prefix), so it stops
falsely warning "Public Bucket URL is missing. Uploads will fail."
Tests: tests/services/s3-upload-prepare-contract.test.mjs (3 — meta flatten, npub ignored
when credentials given, direct-settings back-compat). Build + lint clean; upload/storage
suites 46/46.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The liveness probe already tries all of a video's sources, but actual playback
only ever tried the single primary URL and then fell straight to WebTorrent. Now
playback fails over across hosted mirrors first.
- PlaybackSession builds an ordered, de-duped hostedSourceCandidates (primary URL
first, then the video's imeta `sources`; accepts string or {url} entries).
- attemptHostedPlayback takes a candidateUrl; the url-first execution loop tries
each candidate and advances to the next mirror on probe-fail/stall/timeout,
cleaning up watchdogs + resetting the <video> between candidates, only calling
WebTorrent after every hosted mirror fails.
- `sources` plumbed coordinator -> strategy -> session. Single-source videos
(no `sources`) behave exactly as before.
Tests: tests/services/playbackService_multisource.test.mjs (candidate
construction; the full async fail-over flow is covered by the existing
playbackService_order suite in CI — that mock-timers harness doesn't run in this
sandbox). ROLLBACK: revert the loop to the single attemptHostedPlayback() call
and drop the sources plumbing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two fixes for hosted (CDN) playback that users hit on a torrent-first instance:
1. Reliability: clear crossOrigin before direct hosted playback (in
resetVideoElement and right before video.src = httpsUrl). A leftover
crossOrigin="anonymous" from a prior WebTorrent attempt on the reused
<video> forced a CORS request that CDNs / redirect targets (e.g. archive.org
storage nodes) reject, failing with MEDIA_ERR_SRC_NOT_SUPPORTED ("source not
supported or blocked"). Direct <video> playback never needs CORS, so any URL
now plays.
2. CDN-first for dual-source: when a hosted URL exists, play it first for
instant, reliable startup; WebTorrent is reserved for URL-less videos (and
explicit forcedSource: "torrent"). Previously a torrent-first instance made
dual-source videos wait on a P2P cold start before falling back to the CDN.
The torrent-first branch is now only reached without a URL, so it waits
indefinitely for peers (no plan B) rather than on a fixed timeout.
Tests: hosted playback clears crossOrigin and plays; a torrent-first instance
plays the URL first when one exists and never calls WebTorrent; URL-less videos
still use WebTorrent. Spec-corrected two playbackService_order tests that
encoded the old torrent-first-for-dual-source behavior. All mutation-verified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Drives the real s3UploadService.uploadVideo for a generic (vhost-style) S3
connection with only the S3 I/O mocked, then feeds the published note's URLs
into r2Service.deleteVideoStorage. Asserts the upload writes video + .thumb +
.torrent, the note is valid (URL + magnet with info-hash), and cleanup deletes
exactly those objects using a forcePathStyle-aware S3 client — i.e. the upload
key and the cleanup-derived key agree (the parity property).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two gaps where generic S3 trailed Cloudflare R2:
1. Object cleanup on delete/edit didn't work for generic S3. deleteVideoStorage
built its client via makeR2Client, which hard-forces path-style and ignores
the connection's forcePathStyle -- so vhost-style S3 deletes were misaddressed
and cleanup silently failed (S3 users' deleted/edited files orphaned). It is
now provider-aware (mirrors uploadFile): R2 -> makeR2Client (path-style),
generic S3 -> makeS3Client honoring forcePathStyle. makeS3Client is now
injectable for testing.
2. CORS guidance on upload failure was R2-only. Extracted isLikelyCorsError +
a generic-S3 guidance builder into js/utils/uploadErrorHints.js (re-exported
from r2Service for back-compat); s3UploadService now appends actionable CORS
guidance on an opaque "Failed to fetch" too.
Generic S3 is now at functional parity with R2 for upload -> publish -> cleanup.
Tests: r2-delete-video-storage (adds generic-S3 forcePathStyle routing),
s3-upload-cors-guidance (new). Full suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A new user's top first-upload failure is a missing R2 CORS policy (browser
PUT/POST blocked) -- and object-scoped R2 tokens often cannot auto-set CORS.
The connection test already surfaces buildCorsGuidance on the opaque
"Failed to fetch", but a user who skips the test and uploads directly got a
bare "Upload failed: Failed to fetch". The upload error handler now detects the
browser CORS/network rejection (isLikelyCorsError -- Chrome "Failed to fetch",
Firefox "NetworkError", Safari "Load failed") and appends the same actionable
guidance. Updated the R2 follow-up TODO (credential audit done; CORS reviewed).
Test: tests/services/r2-cors-error-classifier.test.mjs. Full suite green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Deleting a video removed the Nostr note but left the actual file (plus its
thumbnail and .torrent) publicly downloadable at its R2/S3 URL forever — a
privacy gap and ongoing storage cost. Now the delete flow also removes the
backing objects, best-effort.
- collectVideoStorageKeys() (storagePointer.js): pure helper that derives the
object keys (video + sibling .torrent + thumbnail) from a note, ONLY for URLs
under the owner's bucket base — external/3rd-party URLs are never returned.
- R2Service.deleteVideoStorage(): resolves the owner's connection, and deletes
those keys. Skips cleanly when storage is locked / no creds / no owned
objects, and never throws (failures are recorded, not propagated) so storage
cleanup can't block note deletion. R2Service class now exported for testing.
- app.handleDeleteModalConfirm: calls it after handleFullDeleteVideo succeeds.
Tests: tests/storage-key-collection.test.mjs (4),
tests/services/r2-delete-video-storage.test.mjs (4). Full suite green.
Note: superseded versions orphaned by past edits aren't retroactively cleaned;
edit-time cleanup (next) prevents new orphans going forward.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
These optional-asset uploads are non-fatal, but failures were warn-only — the
user got a silently-missing thumbnail or torrent with no signal. Both upload
services now emit a 'warning' status at the point of failure and append a note
to the final 'Published' status (downgraded to warning variant) listing what
failed. Cloudflare-upload audit #5.
Test: tests/services/s3-upload-optional-asset-failures.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Computing the torrent info-hash (and the content-hash fallback) reads the entire
file before the upload starts and before any progress is emitted, so on
multi-GB videos there was a long silent pause that looked like a freeze. Both
upload services now emit an 'Analyzing video…' status when a full-file hash is
required (i.e. no valid info-hash was pre-supplied). Cloudflare-upload audit #2.
Test: tests/services/s3-upload-analyzing-status.test.mjs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Implements `toLightweightVideo` helper in `js/services/exploreDataService.js` to strip large fields (content, signatures, etc.) from video objects before sending to the worker.
- Updates `buildWatchHistoryTagCounts` and `buildTagIdf` to use lightweight objects.
- Adds `tests/services/exploreDataService.optimization.test.mjs` to verify correctness.
- Adds `benchmarks/worker_transfer_lightweight_bench.js` demonstrating ~40% improvement in transfer time.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Refactor `S3UploadService` to accept dependencies in constructor
- Move `resolveUploadIdentifier` to class method for mocking
- Add comprehensive unit tests for `S3UploadService` covering event emitter, validation, and upload logic
- Ensure `WebTorrent` dependency is mocked or polyfilled in test environment
Co-authored-by: PR0M3TH3AN <[email protected]>
Adds a new test file `tests/services/trustBootstrap.test.mjs` to cover the functionality of `bootstrapTrustedSeeds` in `js/services/trustBootstrap.js`.
The test suite covers:
- Feature flag validation
- Successful seed application when access control is ready
- Subscription to access control changes
- Fallback/timeout handling for access control readiness
- Retry logic for relay readiness
Uses `node:test` and mocks dependencies `accessControl`, `moderationService`, `nostrService` via dynamic imports to handle singleton instantiation side effects.
Co-authored-by: PR0M3TH3AN <[email protected]>
Added focused unit tests for three core modules to improve coverage:
- `js/services/authUtils.js`: Tested `normalizeProviderId` and `normalizeAuthType`.
- `js/nostr/decryptionSchemeCache.js`: Tested caching logic, TTL expiration, and cache clearing.
- `js/nostr/videoPayloadBuilder.js`: Tested payload extraction and event preparation, including NIP-71 metadata handling.
Tests use `node:test` and `node:assert/strict` and are integrated into the existing test suite.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Adds a default connection timeout of 5000ms to `RelayHealthService.checkRelay`.
- Adds comprehensive tests for `RelayHealthService` covering:
- Connection timeouts (mocked).
- Failure thresholds for user logging.
- Logging cooldowns (mocked time).
- Relay `disconnect` and `error` event handling.
- `nostrClient.markRelayUnreachable` integration.
- Refactors tests to use manual mocking for `setTimeout` and `Date.now` for stability.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Add `tests/services/exploreDataService.test.mjs` to test `buildWatchHistoryTagCounts`.
- Mock global `Worker` to simulate worker behavior.
- Mock `console.warn` to verify error logging and suppression.
- Use dynamic import to handle `devLogger` initialization dependent on global flags.
Co-authored-by: PR0M3TH3AN <[email protected]>
This update aligns `tests/services/relay-health-service.test.mjs` with the actual state object structure used in `RelayHealthService`, which now includes `consecutiveFailures` and `lastUserLogAt` properties. This fixes a CI failure in the `agent-review` job.
The previous fix for `tests/app-batch-fetch-profiles.test.mjs` (duplicate variable) is also included in this branch history.
Co-authored-by: PR0M3TH3AN <[email protected]>
Wraps the `respects TTL` test body in a `try...finally` block to ensure `expiredService.db` is closed even if assertions fail, preventing `fake-indexeddb` from hanging during cleanup.
Adds a small delay to the same test to ensure time advances beyond the 0ms TTL (caused by negative TTL clamping), fixing a flaky assertion failure.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Explicitly nullify `service.db` after closing to prevent reuse or stale references.
- Add `setImmediate` yield in `t.afterEach` to allow `fake-indexeddb` connection close events to propagate before `deleteDatabase`.
- Fixes `[LinkPreviewService Test] Database deletion blocked` causing test timeouts.
Co-authored-by: PR0M3TH3AN <[email protected]>
Restores globalThis.fetch after each test in LinkPreviewService test suite to prevent pollution of other tests with mocked implementations.
Co-authored-by: PR0M3TH3AN <[email protected]>
- Added `DEFAULT_PLAYBACK_SOURCE` to `config/instance-config.js` to allow choosing between "url" (default) and "torrent" priority.
- Updated `js/constants.js` to derive `URL_FIRST_ENABLED` flag from the configuration.
- Refactored `PlaybackService.execute()` in `js/services/playbackService.js` to robustly support "Torrent First" logic with fallback to URL, while maintaining "URL First" with fallback to Torrent.
- Added unit tests in `tests/services/playbackService_order.test.mjs` to verify playback order and fallback mechanics.
- Format `css/tailwind.source.css` with Prettier.
- Add allowlist entry for `VideoCard.js` media query pixel value in `scripts/check-design-tokens.mjs`.
- Remove accidental artifact `js/ui/dm/jules_session_4520373547615093905.zip`.
- Fix bug in `js/app.js` and `js/services/moderationActionController.js` where blocked users' videos were not correctly hidden in the UI immediately after blocking.
- Fix bug in `tests/app/moderation-overrides.test.mjs` where `userBlocks` mocks were insufficient to reflect state changes.
- Add `tests/services/link-preview-service.test.mjs`: Tests for caching, fetching, and TTL logic using `fake-indexeddb` and `jsdom`.
- Add `tests/nostr/session-actor.test.mjs`: Tests for encryption/decryption and persistence of session actor.
- Refactor `tests/app/similar-content.test.mjs`: Update to test `SimilarContentController` directly, fixing regression caused by architectural changes.
- Ensure tests are self-contained with necessary polyfills (IndexedDB, LocalStorage).
- Added `tests/state/cache.test.mjs` to test profile persistence and URL health caching.
- Added `tests/nostr/sessionActor.test.mjs` to test session key encryption/decryption and storage.
- Added `tests/services/r2Service.test.mjs` to test R2 settings management and bucket configuration.
- Exposed `__testExports` in `js/nostr/sessionActor.js` to allow testing of private helper functions.
- Used `tests/test-helpers/setup-localstorage.mjs` to mock browser environments in all new tests.