5 Commits

Author SHA1 Message Date
daniel aa4fae1faf Merge pull request 'Merge - Streaming Winner' (#2) from dev into master
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m16s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m27s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 20s
Deploy DeepDrftManager / Deploy (push) Successful in 12s
Deploy DeepDrftPublic / Deploy (push) Successful in 12s
Reviewed-on: #2
2026-06-27 02:47:43 +00:00
daniel-c-harvey 8d1272e36f docs: fix stale anonid.ts path after telemetry module rename
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m1s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m23s
2026-06-26 22:22:05 -04:00
daniel-c-harvey 6a043b622e Merge telemetry-transport-resilience into dev (first-party fetch for play/share; beacon only on unload) 2026-06-26 22:17:27 -04:00
daniel-c-harvey 2af0d8650b fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
2026-06-26 21:11:43 -04:00
daniel d26c11e897 Merge pull request 'chore: Trigger CI' (#1) from dev into master
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m20s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m31s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m24s
Package install tarball / package (push) Successful in 7s
Deploy DeepDrftAPI / Deploy (push) Successful in 22s
Deploy DeepDrftManager / Deploy (push) Successful in 13s
Deploy DeepDrftPublic / Deploy (push) Successful in 15s
Reviewed-on: #1
2026-06-23 12:51:08 +00:00
17 changed files with 319 additions and 115 deletions
+1 -1
View File
@@ -324,7 +324,7 @@ _Note: Two distinct efforts share the "Phase 18" label — phase numbers are org
- **Shape:**
- **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`.
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/session/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
- **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically).
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.
+5 -4
View File
@@ -53,10 +53,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`CoerceTheaterMode()`**: enforces the invariant that Theater Mode cannot remain on when both subsystems are off — called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` **before** `NotifyChanged()` so all observers see a consistent, coerced state in the same `Changed` cycle. **`ApplyCapabilityDefault(bool hardwareAccelerated)`**: one-time scoped capability default (guarded by `_capabilityDefaultApplied`; never re-applies on SPA navigation, never overrides an explicit in-session toggle). When `hardwareAccelerated` is false (positive software-renderer match from `hwAccel.ts`'s `UNMASKED_RENDERER_WEBGL` probe, or total WebGL failure) sets `LavaEnabled = false` while leaving `WaveformEnabled` at its default on, then calls `CoerceTheaterMode()` + `NotifyChanged()` once so all observers see the default in a single cycle. Called by the visualizer bridge on first interactive render once JS interop (the HW-accel probe via `detectHardwareAcceleration()` exported from `WaveformVisualizer.ts`) is available; a no-op when HW accel is present. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. A share is always a live-page user interaction (never a tab-unload), so it sends via the first-party `IEventPoster` fetch **only** — no `sendBeacon` arm. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `IEventPoster` / `HttpEventPoster`: First-party same-origin event transport (telemetry transport-resilience). `PostAsync(url, json)` POSTs an `application/json` body to the host's own `api/event/*` proxy via a default `IHttpClientFactory` client, best-effort and non-throwing. A first-party fetch is not name-matched by tracking/fingerprinting heuristics the way a `telemetry/beacon` `sendBeacon` module is — this is the transport for normal play closes (end/switch/stop) and every share. One seam so the play sink and share tracker share it and tests capture the wire payload behind a fake.
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper over `window.DeepDrftLifecycle` (served from `js/session/lifecycle.js`). After the transport-resilience split it is the **unload-edge transport only**: it fires the play payload via `sendBeacon` on page tear-down (where an awaited fetch would be cancelled) and wires the page-unload handler. Normal closes go over `IEventPoster`. Named off the former `telemetry/beacon` path so the retained fallback isn't name-matched either.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1; transport-resilience split). Serializes the play classification once and dispatches down the arm the close chose: `EmitPlayAsync` over the first-party `IEventPoster` (normal close: organic end / track-switch / stop, page alive) and `EmitPlayOnUnload` over `BeaconInterop` `sendBeacon` (tab-unload edge). Both arms send byte-identical payloads. `PlayTracker.Close(bool viaUnload)` selects the arm — `OnPageUnload` passes `viaUnload: true`, every other close defaults to fetch. **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/session/anonid.ts` (exposes `window.DeepDrftAnonId`, served from `js/session/anonid.js`; mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` caches `QueueService.Items` as a `_queueItemsCache` snapshot (the service exposes its backing list by reference); the cache is invalidated and set to `null` in `OnQueueChanged`, so every real mutation hands `QueueList` a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. `QueueList.OnParametersSet` calls `_dropContainer?.Refresh()` so the `MudDropContainer` re-reads the new list and the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
@@ -3,11 +3,16 @@ using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
/// touches <see cref="IJSRuntime"/> string identifiers directly. All calls are best-effort: a JS
/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
/// never throw into the UI or the playback path.
/// Thin C# wrapper over the <c>window.DeepDrftLifecycle</c> TS interop. Wraps the <c>navigator.sendBeacon</c>
/// POST and the page-unload registration so the rest of the client never touches <see cref="IJSRuntime"/>
/// string identifiers directly. After the transport-resilience split this is the <b>unload-edge transport
/// only</b>: normal play closes and shares go over the first-party <see cref="IEventPoster"/> fetch, and
/// <c>sendBeacon</c> is retained solely for the page-unload path (pagehide / visibility→hidden) where an
/// awaited fetch would be cancelled. The module is named off the former <c>telemetry/beacon</c> path
/// (<c>DeepDrftLifecycle</c>, served from <c>js/session/lifecycle.js</c>) so even this retained fallback is
/// not caught by name-based tracking/fingerprinting blockers. All calls are best-effort: a JS failure
/// (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must never throw
/// into the UI or the playback path.
/// </summary>
public sealed class BeaconInterop
{
@@ -23,7 +28,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
await _js.InvokeAsync<bool>("DeepDrftLifecycle.send", url, json);
}
catch
{
@@ -37,7 +42,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
await _js.InvokeVoidAsync("DeepDrftLifecycle.registerUnload", key, dotNetRef, methodName);
}
catch
{
@@ -50,7 +55,7 @@ public sealed class BeaconInterop
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
await _js.InvokeVoidAsync("DeepDrftLifecycle.unregisterUnload", key);
}
catch
{
@@ -7,28 +7,38 @@ using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
/// awaiting and its failure is irrelevant. The current <c>anonId</c> (wave 16.3) is read synchronously
/// from the warmed <see cref="IAnonIdProvider"/> cache and omitted when null (storage unavailable / not
/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally.
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2; telemetry transport-resilience). Serializes the
/// play classification once and dispatches it down the arm the close chose:
/// <list type="bullet">
/// <item><see cref="EmitPlayAsync"/> posts via the first-party <see cref="IEventPoster"/> — a same-origin
/// <c>HttpClient</c> fetch to <c>api/event/play</c>, used for the normal close paths (organic end /
/// track-switch / stop) that a privacy-hardened browser would block if they used a name-matched
/// <c>sendBeacon</c> module.</item>
/// <item><see cref="EmitPlayOnUnload"/> fires <see cref="BeaconInterop"/> (<c>sendBeacon</c>) for the
/// tab-unload edge, where an awaited fetch would be cancelled.</item>
/// </list>
/// Both arms send byte-identical payloads (same DTO, same anonId, same JSON options). The current
/// <c>anonId</c> (wave 16.3) is read synchronously from the warmed <see cref="IAnonIdProvider"/> cache and
/// omitted when null (storage unavailable / not yet warmed) — an anonId-less play still counts, it just
/// doesn't contribute to the listener tally.
/// </summary>
public sealed class BeaconPlayEventSink : IPlayEventSink
{
// Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than
// sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism;
// it does not change the integer enum encoding the 16.1 contract already relies on.
private static readonly JsonSerializerOptions BeaconJson =
private static readonly JsonSerializerOptions EventJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly IEventPoster _poster;
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _playUrl;
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
public BeaconPlayEventSink(
IEventPoster poster, BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_poster = poster;
_beacon = beacon;
_anonId = anonId;
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
@@ -36,17 +46,19 @@ public sealed class BeaconPlayEventSink : IPlayEventSink
_playUrl = $"{navigation.BaseUri}api/event/play";
}
public void EmitPlay(string trackEntryKey, PlayBucket bucket)
{
var json = JsonSerializer.Serialize(new PlayEventDto
public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket)
=> _poster.PostAsync(_playUrl, Serialize(trackEntryKey, bucket));
public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket)
// Fire-and-forget: the beacon survives unload; the C# task may not, and we do not act on the
// result either way.
=> _ = _beacon.SendAsync(_playUrl, Serialize(trackEntryKey, bucket));
private string Serialize(string trackEntryKey, PlayBucket bucket)
=> JsonSerializer.Serialize(new PlayEventDto
{
TrackEntryKey = trackEntryKey,
Bucket = bucket,
AnonId = _anonId.Current,
}, BeaconJson);
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
// act on the result either way.
_ = _beacon.SendAsync(_playUrl, json);
}
}, EventJson);
}
@@ -0,0 +1,46 @@
using System.Text;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IEventPoster"/>: a first-party, same-origin <see cref="System.Net.Http.HttpClient"/>
/// POST to the site's own <c>api/event/*</c> proxy (telemetry transport-resilience). A fetch to the host's
/// own origin is not third-party tracking and is not caught by the name-based heuristics (Firefox
/// Fingerprinting / Tracking Protection) that block a <c>telemetry/beacon</c>-named <c>sendBeacon</c>
/// module. Used for the awaitable play-close paths (organic end / track-switch / stop) and every share
/// event; only the rare tab-unload edge still goes through <see cref="BeaconInterop"/>, where an awaited
/// fetch would be cancelled as the page freezes.
///
/// <para>Best-effort and non-throwing by contract: a failed POST (offline, blocked, server error) is
/// swallowed so a dropped telemetry event never throws into the UI or the playback path — identical
/// posture to the beacon transport.</para>
/// </summary>
public sealed class HttpEventPoster : IEventPoster
{
// The default factory client carries no base address; the sink/tracker pass an absolute same-origin
// URL built from NavigationManager.BaseUri, so the POST targets the host proxy regardless of how the
// named clients are configured. The default client uses the browser fetch handler in WASM, which is
// exactly the first-party request the heuristic blockers permit.
private readonly IHttpClientFactory _httpClientFactory;
public HttpEventPoster(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task PostAsync(string url, string json)
{
try
{
using var client = _httpClientFactory.CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
// Best-effort: the server records the event; we do not act on the status either way.
using var response = await client.PostAsync(url, content);
}
catch
{
// Swallow — a dropped telemetry event is acceptable; telemetry must never throw into the UI
// or the playback path. Mirrors the beacon's fire-and-forget contract.
}
}
}
@@ -0,0 +1,19 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The first-party event transport seam (telemetry transport-resilience). Sends a serialized event body
/// to a same-origin <c>api/event/*</c> route over <see cref="System.Net.Http.HttpClient"/> — a fetch to
/// the site's own host proxy, which privacy / tracking heuristics do not block the way they name-match a
/// <c>sendBeacon</c> module. Abstracted so the play sink and the share tracker share one best-effort,
/// non-throwing POST, and so tests can capture the wire payload behind a fake with no HTTP — the same
/// one-seam pattern as <see cref="IPlayEventSink"/>.
/// </summary>
public interface IEventPoster
{
/// <summary>
/// Best-effort POST of <paramref name="json"/> (an <c>application/json</c> body) to
/// <paramref name="url"/>. Never throws: a failed POST is swallowed so telemetry cannot break the UI
/// or the playback path. Awaitable, but safe to fire-and-forget on a live page.
/// </summary>
Task PostAsync(string url, string json);
}
@@ -5,12 +5,30 @@ namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The emit seam for the <see cref="PlayTracker"/> (Phase 16 §2.1). The tracker owns the session
/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
/// it hands a finished classification to a sink. The production sink fires a <c>sendBeacon</c> POST to
/// <c>api/event/play</c>; tests substitute a fake sink to assert floor and bucket behaviour with no
/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
/// it hands a finished classification to a sink, choosing only which arm fits the close that triggered
/// it. Two arms exist because the close paths differ in whether the page survives the call (telemetry
/// transport-resilience):
/// <list type="bullet">
/// <item><see cref="EmitPlayAsync"/> — normal closes (organic end / track-switch / stop), where the page
/// stays alive, go over a first-party <c>HttpClient</c> POST to <c>api/event/play</c>. A first-party
/// fetch is not name-matched by tracking/fingerprinting heuristics the way a <c>sendBeacon</c> module is.</item>
/// <item><see cref="EmitPlayOnUnload"/> — the page-unload edge (pagehide / visibility→hidden), where an
/// awaited fetch would be cancelled, still goes over <c>navigator.sendBeacon</c>.</item>
/// </list>
/// Tests substitute a fake sink to assert floor and bucket behaviour with no transport.
/// </summary>
public interface IPlayEventSink
{
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
void EmitPlay(string trackEntryKey, PlayBucket bucket);
/// <summary>
/// Emit one recorded play over the first-party fetch transport (normal close: end / switch / stop).
/// Called at most once per session, only when the floor is crossed. Awaitable but safe to
/// fire-and-forget on a live page; never throws.
/// </summary>
Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket);
/// <summary>
/// Emit one recorded play over <c>sendBeacon</c> for the page-unload edge, where an awaited fetch
/// would be cancelled as the page freezes. Synchronous and fire-and-forget; never throws.
/// </summary>
void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket);
}
+13 -2
View File
@@ -88,8 +88,15 @@ public sealed class PlayTracker
/// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
/// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
/// first call emits.
///
/// <para><paramref name="viaUnload"/> selects the transport, not the classification (telemetry
/// transport-resilience). The default (false) is the normal close (organic end / track-switch / stop):
/// the page is alive, so the event goes over the first-party fetch arm. The unload handler passes true
/// so the rare tab-close mid-play uses <c>sendBeacon</c>, the only transport that survives the freeze.
/// The fetch arm is fire-and-forget here because the close paths are sync-shaped (a void JS callback,
/// or a teardown we must not block on a telemetry POST) — on a live page the task still completes.</para>
/// </summary>
public void Close()
public void Close(bool viaUnload = false)
{
if (!HasOpenSession)
{
@@ -112,7 +119,11 @@ public sealed class PlayTracker
if (!CrossesFloor(_highWater, duration))
return;
_sink.EmitPlay(key, Classify(fraction));
var bucket = Classify(fraction);
if (viaUnload)
_sink.EmitPlayOnUnload(key, bucket);
else
_ = _sink.EmitPlayAsync(key, bucket);
}
// The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
+13 -10
View File
@@ -10,13 +10,16 @@ namespace DeepDrftPublic.Client.Services;
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
/// fires the event via the first-party <see cref="IEventPoster"/> POST to the proxied <c>api/event/share</c>
/// route. A share is always a user-interaction close with the page alive (never a tab-unload), so it uses
/// the fetch transport unconditionally — there is no <c>sendBeacon</c> arm here (telemetry
/// transport-resilience).
///
/// <para>
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
/// The beacon send is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from
/// the warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
/// The POST is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from the
/// warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
/// </para>
/// </summary>
public sealed class ShareTracker
@@ -27,17 +30,17 @@ public sealed class ShareTracker
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
// change the integer enum encoding the 16.1 contract relies on.
private static readonly JsonSerializerOptions BeaconJson =
private static readonly JsonSerializerOptions EventJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IEventPoster _poster;
private readonly IAnonIdProvider _anonId;
private readonly string _shareUrl;
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
public ShareTracker(IEventPoster poster, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_poster = poster;
_anonId = anonId;
_shareUrl = $"{navigation.BaseUri}api/event/share";
}
@@ -71,10 +74,10 @@ public sealed class ShareTracker
TargetKey = targetKey,
Channel = channel,
AnonId = _anonId.Current,
}, BeaconJson);
}, EventJson);
// Fire-and-forget — a dropped share telemetry event is acceptable.
_ = _beacon.SendAsync(_shareUrl, json);
// Fire-and-forget first-party POST — a dropped share telemetry event is acceptable.
_ = _poster.PostAsync(_shareUrl, json);
return true;
}
}
@@ -121,7 +121,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
/// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
/// </summary>
[JSInvokable]
public void OnPageUnload() => _playTracker?.Close();
public void OnPageUnload() => _playTracker?.Close(viaUnload: true);
// Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
// lowers it — the tracker takes the max.
+10 -6
View File
@@ -42,12 +42,16 @@ public static class Startup
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
services.AddScoped<WaveformVisualizerControlState>();
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
// share tracker fire events through it. The play tracker itself is NOT registered — the player
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
// is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider
// (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the
// session, warmed when a surface goes interactive (the player provider, the share popover).
// Phase 16 anonymous telemetry (client side), transport-resilience split. IEventPoster is the
// first-party HttpClient POST used for normal play closes (end/switch/stop) and every share — a
// same-origin fetch that privacy/tracking heuristics don't name-match. BeaconInterop wraps
// sendBeacon and is retained only for the tab-unload edge. The play sink picks the arm; the share
// tracker is fetch-only. The play tracker itself is NOT registered the player is not
// DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker is
// scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider (wave
// 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the session,
// warmed when a surface goes interactive (the player provider, the share popover).
services.AddScoped<IEventPoster, HttpEventPoster>();
services.AddScoped<BeaconInterop>();
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
+2 -2
View File
@@ -27,8 +27,8 @@
<script type="module">
import('./js/settings/settings.js');
import('./js/audio/index.js');
import('./js/telemetry/beacon.js');
import('./js/telemetry/anonid.js');
import('./js/session/lifecycle.js');
import('./js/session/anonid.js');
</script>
</body>
</html>
@@ -8,7 +8,8 @@
* iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is
* the known, accepted direction of error (§3).
*
* Exposed on window.DeepDrftAnonId; imported once in App.razor alongside the audio engine and beacon.
* Exposed on window.DeepDrftAnonId; served from js/session/anonid.js, imported once in App.razor
* alongside the audio engine and the unload-lifecycle module.
*/
const STORAGE_KEY = 'deepdrft.anonId';
@@ -1,10 +1,16 @@
/**
* Telemetry beacon interop (Phase 16 §2.2). A thin wrapper over navigator.sendBeacon for fire-and-forget
* play/share events, plus a page-unload handler that lets the player close an open play session as the
* tab goes away. sendBeacon (not fetch) is the load-bearing choice: it survives page unload, where a
* fetch would be cancelled exactly the tab-close edge case the play metric must still record.
* Page-lifecycle unload transport. A thin wrapper over navigator.sendBeacon for the single edge case where
* an awaited fetch cannot run: the page is being torn down (tab close, navigation, bfcache freeze, mobile
* backgrounding). It exposes a sendBeacon POST plus a page-unload handler that lets the player close an
* open play session as the tab goes away. sendBeacon (not fetch) is the load-bearing choice here: it
* survives page unload, where a fetch would be cancelled.
*
* Exposed on window.DeepDrftBeacon; imported once in App.razor alongside the audio engine.
* Normal play closes (organic end / track-switch / stop) and all share events do NOT use this module
* they go over a first-party same-origin HttpClient POST from C#, which privacy/tracking heuristics do not
* block. This module is named off the former telemetry/beacon path (DeepDrftLifecycle, served from
* js/session/lifecycle.js) so even this retained unload fallback is not caught by name-based blockers.
*
* Exposed on window.DeepDrftLifecycle; imported once in App.razor alongside the audio engine.
*/
// .NET interop type — a DotNetObjectReference the unload handler invokes back into.
@@ -47,11 +53,11 @@ function wireUnloadOnce(): void {
});
}
const DeepDrftBeacon = {
const DeepDrftLifecycle = {
/**
* Queue a fire-and-forget POST of a small JSON body. Returns false if the browser refused to queue
* the beacon (e.g. over the per-origin byte budget) callers ignore it; a dropped telemetry event
* is acceptable by design.
* Queue a fire-and-forget sendBeacon POST of a small JSON body, for the page-unload edge only. Returns
* false if the browser refused to queue the beacon (e.g. over the per-origin byte budget) callers
* ignore it; a dropped telemetry event is acceptable by design.
*/
send: (url: string, json: string): boolean => {
try {
@@ -79,10 +85,10 @@ const DeepDrftBeacon = {
declare global {
interface Window {
DeepDrftBeacon: typeof DeepDrftBeacon;
DeepDrftLifecycle: typeof DeepDrftLifecycle;
}
}
window.DeepDrftBeacon = DeepDrftBeacon;
window.DeepDrftLifecycle = DeepDrftLifecycle;
export { DeepDrftBeacon };
export { DeepDrftLifecycle };
+68 -30
View File
@@ -7,24 +7,38 @@ using Microsoft.JSInterop;
namespace DeepDrftTests;
/// <summary>
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the event payloads emitted by
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
/// no token. After the transport-resilience split, normal play closes and shares serialize and POST over
/// the first-party <see cref="IEventPoster"/>, so those assertions capture the JSON off a fake poster.
/// The play sink's unload arm still serializes the same bytes through <c>BeaconInterop</c> →
/// <c>DeepDrftLifecycle.send</c>, asserted off a fake JS runtime — proving both arms carry the id.
/// </summary>
[TestFixture]
public class AnonIdPayloadTests
{
// Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
// fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
// Captures the JSON body of the most recent first-party POST. The poster is fire-and-forget; the
// caller never reads its result.
private sealed class CapturingEventPoster : IEventPoster
{
public string? LastJson { get; private set; }
public Task PostAsync(string url, string json)
{
LastJson = json;
return Task.CompletedTask;
}
}
// Captures the JSON body of the most recent DeepDrftLifecycle.send(url, json) invocation (the unload
// arm). Other interop calls (unload registration) are tolerated and ignored.
private sealed class CapturingJsRuntime : IJSRuntime
{
public string? LastJson { get; private set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
if (identifier == "DeepDrftLifecycle.send" && args is { Length: 2 } && args[1] is string json)
LastJson = json;
return ValueTask.FromResult<TValue>(default!);
}
@@ -33,6 +47,12 @@ public class AnonIdPayloadTests
=> InvokeAsync<TValue>(identifier, args);
}
// A no-op poster for the unload-arm test, where the beacon (not the poster) is the asserted transport.
private sealed class NoopEventPoster : IEventPoster
{
public Task PostAsync(string url, string json) => Task.CompletedTask;
}
private sealed class StubAnonIdProvider : IAnonIdProvider
{
public StubAnonIdProvider(string? current) => Current = current;
@@ -64,61 +84,79 @@ public class AnonIdPayloadTests
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
// A play emitted while the provider holds a token carries that token in the payload.
private static BeaconPlayEventSink PlaySink(IEventPoster poster, IJSRuntime js, string? anonId)
=> new(poster, new BeaconInterop(js), new StubAnonIdProvider(anonId), new TestNavigationManager());
// --- Play sink, first-party fetch arm (normal close) ---
// A play emitted while the provider holds a token carries that token in the fetch payload.
[Test]
public void PlaySink_WithAnonId_IncludesItInPayload()
public async Task PlaySink_FetchArm_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var sink = new BeaconPlayEventSink(
new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());
var poster = new CapturingEventPoster();
var sink = PlaySink(poster, new CapturingJsRuntime(), "listener-42");
sink.EmitPlay("track-key", PlayBucket.Complete);
await sink.EmitPlayAsync("track-key", PlayBucket.Complete);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-42"));
}
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
// entirely rather than sending anonId:null.
[Test]
public void PlaySink_WithoutAnonId_OmitsItFromPayload()
public async Task PlaySink_FetchArm_WithoutAnonId_OmitsItFromPayload()
{
var poster = new CapturingEventPoster();
var sink = PlaySink(poster, new CapturingJsRuntime(), null);
await sink.EmitPlayAsync("track-key", PlayBucket.Partial);
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False, "null anonId is omitted from the wire payload");
}
// --- Play sink, sendBeacon arm (page unload) ---
// The unload arm serializes the same payload through sendBeacon, carrying the token too.
[Test]
public void PlaySink_UnloadArm_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var sink = new BeaconPlayEventSink(
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
var sink = PlaySink(new NoopEventPoster(), js, "listener-99");
sink.EmitPlay("track-key", PlayBucket.Partial);
sink.EmitPlayOnUnload("track-key", PlayBucket.Complete);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-99"));
}
// --- Share tracker (always first-party fetch) ---
// A share recorded while the provider holds a token carries it in the payload.
[Test]
public void ShareTracker_WithAnonId_IncludesItInPayload()
{
var js = new CapturingJsRuntime();
var tracker = new ShareTracker(
new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());
var poster = new CapturingEventPoster();
var tracker = new ShareTracker(poster, new StubAnonIdProvider("listener-7"), new TestNavigationManager());
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(ReadAnonId(poster.LastJson!), Is.EqualTo("listener-7"));
}
// A share recorded with no token omits anonId from the payload.
[Test]
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
{
var js = new CapturingJsRuntime();
var tracker = new ShareTracker(
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
var poster = new CapturingEventPoster();
var tracker = new ShareTracker(poster, new StubAnonIdProvider(null), new TestNavigationManager());
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
Assert.That(js.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
Assert.That(poster.LastJson, Is.Not.Null);
Assert.That(HasAnonIdProperty(poster.LastJson!), Is.False);
}
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
+48 -2
View File
@@ -13,11 +13,27 @@ namespace DeepDrftTests;
[TestFixture]
public class PlayTrackerTests
{
// Captures emitted plays so assertions read the (key, bucket) the tracker classified.
// Captures emitted plays so assertions read the (key, bucket) the tracker classified. The two arms are
// captured separately so a test can assert which transport a given close selected (fetch vs unload).
// Emitted folds both arms for the floor/bucket assertions that don't care about transport.
private sealed class FakeSink : IPlayEventSink
{
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket));
public List<(string Key, PlayBucket Bucket)> FetchEmitted { get; } = new();
public List<(string Key, PlayBucket Bucket)> UnloadEmitted { get; } = new();
public Task EmitPlayAsync(string trackEntryKey, PlayBucket bucket)
{
FetchEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
return Task.CompletedTask;
}
public void EmitPlayOnUnload(string trackEntryKey, PlayBucket bucket)
{
UnloadEmitted.Add((trackEntryKey, bucket));
Emitted.Add((trackEntryKey, bucket));
}
}
private FakeSink _sink = null!;
@@ -202,4 +218,34 @@ public class PlayTrackerTests
PlaySession("t", duration: 100, highWater: 95);
Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
}
// --- Transport-arm selection (telemetry transport-resilience) ---
// A normal close (organic end / track-switch / stop) emits over the first-party fetch arm — the page
// is alive, so the awaitable HttpClient POST is the heuristic-safe transport.
[Test]
public void Close_NormalClose_EmitsOverFetchArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(); // viaUnload defaults to false
Assert.That(_sink.FetchEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.UnloadEmitted, Is.Empty);
}
// The page-unload close emits over the sendBeacon arm — an awaited fetch would be cancelled as the
// page freezes, so this rare edge keeps the beacon.
[Test]
public void Close_ViaUnload_EmitsOverBeaconArm()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(100);
_tracker.OnProgress(95);
_tracker.Close(viaUnload: true);
Assert.That(_sink.UnloadEmitted, Has.Count.EqualTo(1));
Assert.That(_sink.FetchEmitted, Is.Empty);
}
}
+7 -13
View File
@@ -1,33 +1,27 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
/// through a beacon that wraps <see cref="IJSRuntime"/>; the tests use a no-op JS runtime (the send is
/// through the first-party <see cref="IEventPoster"/>; the tests use a no-op poster (the POST is
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
/// recorder returns — true when an event fired, false when debounced.
/// </summary>
[TestFixture]
public class ShareTrackerTests
{
// sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that
// returns default for any invocation is sufficient to exercise the debounce logic.
private sealed class NoopJsRuntime : IJSRuntime
// The first-party POST is fire-and-forget; the tracker never reads the result, so a no-op poster that
// completes immediately is sufficient to exercise the debounce logic.
private sealed class NoopEventPoster : IEventPoster
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
public Task PostAsync(string url, string json) => Task.CompletedTask;
}
// Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL.
// Minimal NavigationManager so the tracker can compose the (unused-in-test) event URL.
private sealed class TestNavigationManager : NavigationManager
{
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
@@ -49,7 +43,7 @@ public class ShareTrackerTests
[SetUp]
public void SetUp()
=> _tracker = new ShareTracker(
new BeaconInterop(new NoopJsRuntime()),
new NoopEventPoster(),
new StubAnonIdProvider("anon-1"),
new TestNavigationManager());