[Track Stats] Implement the stats cache.

This CL makes it so that stats are only updated if this is the first
time in the task execution cycle that the getters are invoked,
preserving JS run-to-completion semantics, and otherwise returns
cached values.

This CL also moves the responsibility of maybe-updating into the
MediaStreamTrackVideoStats interface. This allows keeping direct
references to the stats object and calling videoStats.totalFrames etc
directly without first calling track.videoStats, as was previously
required (a bug).

WPTs for both features are added.

Bug: chromium:1472978
Change-Id: I8f2cddbb9b9d4a1a8ff5c3b11b22ea6613de2084
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4865101
Reviewed-by: Tony Herre <toprice@chromium.org>
Reviewed-by: Harald Alvestrand <hta@chromium.org>
Commit-Queue: Henrik Boström <hbos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1198917}
diff --git a/mediacapture-extensions/MediaStreamTrack-video-stats.https.html b/mediacapture-extensions/MediaStreamTrack-video-stats.https.html
index a585ad2..461be1f 100644
--- a/mediacapture-extensions/MediaStreamTrack-video-stats.https.html
+++ b/mediacapture-extensions/MediaStreamTrack-video-stats.https.html
@@ -65,6 +65,39 @@
 }, `discardedFrames increases when frameRate decimation is happening`);
 
 promise_test(async t => {
+  const stream = await navigator.mediaDevices.getUserMedia({video:true});
+  const [track] = stream.getTracks();
+  t.add_cleanup(() => track.stop());
+
+  // Hold a reference directly to the [SameObject] videoStats, bypassing the
+  // track.videoStats getter in the subsequent getting of totalFrames.
+  const videoStats = track.videoStats;
+  const firstTotalFrames = videoStats.totalFrames;
+  while (videoStats.totalFrames == firstTotalFrames) {
+    await Promise.resolve();
+  }
+  assert_greater_than(videoStats.totalFrames, firstTotalFrames);
+}, `Counters increase even if we don't call the track.videoStats getter`);
+
+promise_test(async t => {
+  const stream = await navigator.mediaDevices.getUserMedia({video:true});
+  const [track] = stream.getTracks();
+  t.add_cleanup(() => track.stop());
+
+  const resolvedPromise = Promise.resolve();
+  const firstTotalFrames = track.videoStats.totalFrames;
+  // Busy-loop for 100 ms, all within the same task execution cycle.
+  const firstTimeMs = performance.now();
+  while (performance.now() - firstTimeMs < 100) {}
+  // The frame counter should not have changed.
+  assert_equals(track.videoStats.totalFrames, firstTotalFrames);
+  // Even though this promise resolved before we got the stats the first time,
+  // it still takes us to the next task execution cycle.
+  await resolvedPromise;
+  assert_not_equals(track.videoStats.totalFrames, firstTotalFrames);
+}, `Counters do not increase in the same task execution cycle`);
+
+promise_test(async t => {
   const stream = await navigator.mediaDevices.getUserMedia({
           video:{frameRate:{ideal:20}}
       });