Files
bitvid/tests/storage-feed-lifecycle.test.mjs
T

422 lines
14 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
tombstoneFixture,
videoEvent,
ids
} from "./test-helpers/storage-tombstone-fixture.mjs";
import * as feeds from "../js/feedEngine/index.js";
import { createFeedCoordinator } from "../js/app/feedCoordinator.js";
import { createPlaybackCoordinator } from "../js/app/playbackCoordinator.js";
import { VideoListView } from "../js/ui/views/VideoListView.js";
import { dedupeVideos } from "../js/utils/videoDeduper.js";
import * as timestamps from "../js/utils/videoTimestamps.js";
import { FEED_TYPES } from "../js/constants.js";
const logger = { log() {}, warn() {}, error() {} };
async function fixture(t, { failDelayed = false } = {}) {
const f = tombstoneFixture(t);
await f.load();
await f.ingest(
videoEvent("original", 100),
videoEvent("unrelated", 110, { root: "other" })
);
const dom = new JSDOM(
'<!doctype html><body><div id="videoList"></div></body>',
{ url: "http://fixture.invalid" }
);
const root = dom.window.document.getElementById("videoList");
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = dom.window.HTMLElement;
t.after(() => {
if (previousHTMLElement === undefined) delete globalThis.HTMLElement;
else globalThis.HTMLElement = previousHTMLElement;
});
const history = { loadLatest: async () => [] };
const methods = createFeedCoordinator({
...feeds,
watchHistoryService: history,
devLogger: logger,
userLogger: logger,
nostrClient: f.client
});
const playback = createPlaybackCoordinator({
...timestamps,
devLogger: logger,
userLogger: logger,
nostrClient: f.client
});
let release, entered;
const reached = new Promise((r) => (entered = r));
const gate = new Promise((r) => (release = r));
let hold = true;
// Defer actual moderation-stage report-subscription IO, not a custom stage.
f.service.moderationService = {
async setActiveEventIds(activeIds) {
if (hold && activeIds.has("original")) {
hold = false;
entered();
await gate;
}
}
};
f.client.hydrateVideoHistory = async (video) => [
{ created_at: video.created_at, deleted: false }
];
const effects = [];
const app = {
...methods,
nostrService: f.service,
feedEngine: feeds.createFeedEngine(),
videosMap: f.service.getVideosMap(),
blacklistedEventIds: new Set(),
buildRecentFeedRuntime: () => ({}),
buildExploreFeedRuntime: () => ({}),
buildForYouFeedRuntime: () => ({}),
isAuthorBlocked: () => false,
dedupeVideosByRoot: dedupeVideos,
getKnownVideoPostedAt: playback.getKnownVideoPostedAt,
resolveVideoPostedAt: playback.resolveVideoPostedAt,
cacheVideoRootCreatedAt: playback.cacheVideoRootCreatedAt,
decorateVideoModeration: (v) => v,
decorateVideoCreatorIdentity: (v) => v,
emitFeedImpressions(videos) {
effects.push(ids(videos));
},
updateModalSimilarContent() {},
checkRelayHealthWarning() {},
mountVideoListView() {
if (!this.videoListView) return null;
return this.videoListView.mount(root);
}
};
const makeView = () =>
new VideoListView({
document: dom.window.document,
utils: {
dedupeVideos,
getKnownVideoPostedAt: (video) => app.getKnownVideoPostedAt(video)
}
});
if (failDelayed) {
const run = app.feedEngine.run.bind(app.feedEngine);
let first = true;
app.feedEngine.run = async (...args) => {
const fail = first;
first = false;
const result = await run(...args);
if (fail)
throw new Error("controlled engine rejection after production stages");
return result;
};
}
app.videoListView = makeView();
// View-count/zap subscription transport is disabled at its own UI adapter boundary.
app.videoListView.syncViewCountSubscriptions = () => {};
assert.ok(app.registerRecentFeed());
assert.ok(app.registerExploreFeed());
const renders = [];
const originalRender = app.renderVideoList;
app.renderVideoList = function (payload) {
renders.push({
active: this.feedTelemetryState?.activeFeed,
reason: payload.metadata?.reason,
ids: ids(payload.videos),
deleted: payload.videos.filter((v) => v.deleted).map((v) => v.id)
});
return originalRender.call(this, payload);
};
const runs = [];
const refresh = app.refreshFeed;
app.refreshFeed = function (...args) {
const p = refresh.apply(this, args);
runs.push(p);
return p;
};
t.after(() => {
release();
app.videoListView?.destroy();
dom.window.close();
});
return {
...f,
app,
root,
reached,
release,
runs,
renders,
makeView,
history,
effects,
domIds: () =>
Array.from(
root.querySelectorAll('[data-component="video-card"]'),
(node) => node.dataset.videoId
).sort()
};
}
test("control: valid delayed registered recent pipeline renders live cards", async (t) => {
const f = await fixture(t);
const loading = f.app.loadFeedVideos("recent");
await f.reached;
f.release();
await loading;
assert.deepEqual(f.domIds(), ["original", "unrelated"]);
assert.equal(f.app.isFeedActive("recent"), true);
});
test("registered recent pipeline must not restore deleted DOM card after a fresher subscription render", async (t) => {
const f = await fixture(t);
const loading = f.app.loadFeedVideos("recent");
await f.reached;
await f.ingest(videoEvent("delete", 200, { deleted: true }));
await f.runs.at(-1);
assert.deepEqual(f.domIds(), ["unrelated"]);
f.release();
await loading;
assert.deepEqual(f.domIds(), ["unrelated"]);
});
test("registered recent result must not overwrite replacement Explore view after destroy/remount", async (t) => {
const f = await fixture(t);
const loading = f.app.loadFeedVideos("recent");
await f.reached;
await f.ingest(videoEvent("delete", 200, { deleted: true }));
await f.runs.at(-1);
f.app.videoListView.destroy();
f.app.videoListView = f.makeView();
f.app.videoListView.syncViewCountSubscriptions = () => {};
await f.app.loadFeedVideos(FEED_TYPES.EXPLORE);
assert.deepEqual(f.domIds(), ["unrelated"]);
assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true);
f.release();
await loading;
assert.deepEqual(f.domIds(), ["unrelated"]);
assert.equal(
f.app.videoListView.state.feedMetadata.sortOrder,
FEED_TYPES.EXPLORE
);
});
test("control: fully disposed view is not remounted when delayed pipeline completes", async (t) => {
const f = await fixture(t);
const loading = f.app.loadFeedVideos("recent");
await f.reached;
const view = f.app.videoListView;
view.destroy();
f.app.videoListView = null;
f.release();
await loading;
assert.equal(view.container, null);
assert.equal(f.app.videoListView, null);
assert.equal(f.renders.length, 0);
assert.equal(f.effects.length, 0);
assert.deepEqual(f.domIds(), []);
});
test("late old failure returns its fallback without painting over the newer success", async (t) => {
const f = await fixture(t, { failDelayed: true });
f.app.mountVideoListView();
const old = f.app.refreshFeed("recent", {
fallbackVideos: [f.app.videosMap.get("original")]
});
await f.reached;
await f.ingest(videoEvent("delete", 200, { deleted: true }));
await f.app.refreshFeed("recent", { reason: "newest" });
const metadata = f.app.latestFeedMetadata;
const count = f.effects.length;
f.release();
const result = await old;
assert.equal(result.metadata.error, true);
assert.deepEqual(ids(result.videos), ["original"]);
assert.deepEqual(f.domIds(), ["unrelated"]);
assert.equal(f.app.latestFeedMetadata, metadata);
assert.equal(f.effects.length, count);
});
test("reverse completion order never paints the older result even while newest is pending", async (t) => {
const f = await fixture(t);
f.app.mountVideoListView();
const old = f.app.refreshFeed("recent");
await f.reached;
let releaseNew, entered;
const reached = new Promise((r) => (entered = r));
const gate = new Promise((r) => (releaseNew = r));
f.service.moderationService.setActiveEventIds = async () => {
entered();
await gate;
};
t.after(() => releaseNew());
const newest = f.app.refreshFeed("recent", { reason: "newest" });
await reached;
f.release();
const oldResult = await old;
assert.deepEqual(ids(oldResult.videos), ["original", "unrelated"]);
assert.equal(f.renders.length, 0);
releaseNew();
await newest;
assert.equal(f.renders.length, 1);
assert.equal(f.app.latestFeedMetadata.reason, "newest");
});
test("same feed remount preserves replacement metadata and suppresses old side effects", async (t) => {
const f = await fixture(t);
const old = f.app.loadFeedVideos("recent");
await f.reached;
f.app.videoListView.destroy();
f.app.videoListView = f.makeView();
f.app.videoListView.syncViewCountSubscriptions = () => {};
await f.app.loadFeedVideos("recent");
const metadata = f.app.latestFeedMetadata;
const count = f.effects.length;
f.release();
await old;
assert.equal(f.app.latestFeedMetadata, metadata);
assert.equal(f.effects.length, count);
assert.deepEqual(f.domIds(), ["original", "unrelated"]);
});
test("destroyed view still referenced by app cannot be remounted by completion", async (t) => {
const f = await fixture(t);
const old = f.app.loadFeedVideos("recent");
await f.reached;
const view = f.app.videoListView;
view.destroy();
f.release();
await old;
assert.equal(view.container, null);
assert.equal(f.renders.length, 0);
assert.equal(f.effects.length, 0);
});
test("new empty result remains empty after older populated result", async (t) => {
const f = await fixture(t);
const old = f.app.loadFeedVideos("recent");
await f.reached;
await f.ingest(
videoEvent("delete", 200, { deleted: true }),
videoEvent("delete-other", 210, { deleted: true, root: "other" })
);
await f.runs.at(-1);
assert.deepEqual(f.domIds(), []);
const count = f.effects.length;
f.release();
await old;
assert.deepEqual(f.domIds(), []);
assert.equal(f.effects.length, count);
});
test("current engine failure paints supplied live fallback and error metadata", async (t) => {
const f = await fixture(t, { failDelayed: true });
f.app.mountVideoListView();
const current = f.app.refreshFeed("recent", {
fallbackVideos: [f.app.videosMap.get("unrelated")]
});
await f.reached;
f.release();
const payload = await current;
assert.equal(payload.metadata.error, true);
assert.deepEqual(f.domIds(), ["unrelated"]);
assert.equal(f.effects.length, 1);
});
test("current unavailable engine paints fallback", async (t) => {
const f = await fixture(t);
f.app.mountVideoListView();
f.app.feedEngine = null;
const result = await f.app.refreshFeed("recent", {
fallbackVideos: [f.app.videosMap.get("unrelated")]
});
assert.equal(result.metadata.engine, "unavailable");
assert.deepEqual(f.domIds(), ["unrelated"]);
assert.equal(f.effects.length, 1);
});
test("obsolete awaited runtime cannot start engine or alter current telemetry", async (t) => {
const f = await fixture(t);
f.app.mountVideoListView();
f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU);
let release, entered;
const reached = new Promise((r) => (entered = r));
const gate = new Promise((r) => (release = r));
f.history.loadLatest = async () => {
entered();
await gate;
return [];
};
t.after(() => release());
const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU, { fallbackVideos: [] });
await reached;
// A different feed becomes current before history IO completes.
f.release();
await f.app.loadFeedVideos(FEED_TYPES.EXPLORE);
const metadata = f.app.latestFeedMetadata;
const count = f.effects.length;
release();
const result = await old;
assert.equal(result.metadata.engine, "superseded");
assert.equal(f.app.latestFeedMetadata, metadata);
assert.equal(f.effects.length, count);
assert.equal(f.app.isFeedActive(FEED_TYPES.EXPLORE), true);
});
test("current awaited history runtime completes registered For You rendering and telemetry", async (t) => {
const f = await fixture(t);
assert.ok(f.app.registerForYouFeed());
f.app.mountVideoListView();
f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU);
let entered, release;
const reached = new Promise((r) => (entered = r));
const gate = new Promise((r) => (release = r));
f.history.loadLatest = async () => {
entered();
await gate;
return [];
};
t.after(() => release());
const current = f.app.refreshFeed(FEED_TYPES.FOR_YOU);
await reached;
release();
await f.reached;
f.release();
const result = await current;
assert.deepEqual(ids(result.videos), ["original", "unrelated"]);
assert.deepEqual(f.domIds(), ["original", "unrelated"]);
assert.equal(result.metadata.preserveOrder, true);
});
for (const failDelayed of [false, true])
test(`stale For You ${failDelayed ? "failure" : "success"} cannot update telemetry or impressions`, async (t) => {
const f = await fixture(t, { failDelayed });
assert.ok(f.app.registerForYouFeed());
f.app.mountVideoListView();
f.app.setFeedTelemetryContext(FEED_TYPES.FOR_YOU);
const updates = [];
f.app.updateFeedTelemetryMetadata = (...args) => updates.push(args);
const old = f.app.refreshFeed(FEED_TYPES.FOR_YOU);
await f.reached;
f.app.videoListView.destroy();
f.app.videoListView = f.makeView();
f.app.videoListView.syncViewCountSubscriptions = () => {};
await f.app.loadFeedVideos(FEED_TYPES.EXPLORE);
const count = f.effects.length;
const metadata = f.app.latestFeedMetadata;
f.release();
await old;
assert.equal(updates.length, 0);
assert.equal(f.effects.length, count);
assert.equal(f.app.latestFeedMetadata, metadata);
});
test("retained obsolete load callback cannot start refresh after a same-feed remount", async (t) => {
const f = await fixture(t);
const load = f.service.loadVideos.bind(f.service);
const callbacks = [];
f.service.loadVideos = (opts) => {
callbacks.push(opts.onVideos);
return load(opts);
};
const old = f.app.loadFeedVideos("recent");
await f.reached;
f.app.videoListView.destroy();
f.app.videoListView = f.makeView();
f.app.videoListView.syncViewCountSubscriptions = () => {};
await f.app.loadFeedVideos("recent");
const count = f.runs.length;
const effects = f.effects.length;
callbacks[0]([], { reason: "obsolete" });
assert.equal(f.runs.length, count);
assert.equal(f.effects.length, effects);
f.release();
await old;
});