mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
fix(embed): implement robust EmbedPlayerModal and guard resetTorrentStats
- Add `js/ui/components/EmbedPlayerModal.js` to implement the `VideoModal` interface for embed contexts (including `resetStats`). - Update `js/ui/ModalManager.js` to detect embed mode and use `EmbedPlayerModal`. - Clean up `js/embed.js` to remove local modal definition and add `postMessage` diagnostic logging. - Update `js/app.js` `resetTorrentStats` log message and verify guard. - Add unit tests for `EmbedPlayerModal` and `resetTorrentStats` logic. - Add E2E reproduction script `tests/e2e/repro_issue.spec.ts`.
This commit is contained in:
1 parent
d71d2812f4
commit
981fac785a
7 files changed
+379
-117
No files matched your search
@@ -4906,7 +4906,7 @@ class Application {
|
|||||||
this.videoModal.resetStats();
|
this.videoModal.resetStats();
|
||||||
} else {
|
} else {
|
||||||
devLogger.info(
|
devLogger.info(
|
||||||
"[Application] resetTorrentStats: videoModal.resetStats not available in this context — skipping."
|
"[Application] resetTorrentStats: videoModal.resetStats not available — skipping."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-97
@@ -5,13 +5,52 @@ import { resolveVideoPointer } from "./utils/videoPointer.js";
|
|||||||
import { devLogger, userLogger } from "./utils/logger.js";
|
import { devLogger, userLogger } from "./utils/logger.js";
|
||||||
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
|
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
|
||||||
|
|
||||||
|
// Diagnostic forwarder for embed debugging
|
||||||
|
function postToParent(type, payload) {
|
||||||
|
try {
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({ __bitvid_debug: true, type, payload }, "*");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", (ev) => {
|
||||||
|
postToParent("error", {
|
||||||
|
message: ev.message,
|
||||||
|
filename: ev.filename,
|
||||||
|
lineno: ev.lineno,
|
||||||
|
colno: ev.colno,
|
||||||
|
stack: ev.error && ev.error.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", (ev) => {
|
||||||
|
postToParent("unhandledrejection", {
|
||||||
|
reason: (ev.reason && ev.reason.stack) || String(ev.reason),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["log", "info", "warn", "error", "debug"].forEach((level) => {
|
||||||
|
if (console[level]) {
|
||||||
|
const orig = console[level].bind(console);
|
||||||
|
console[level] = (...args) => {
|
||||||
|
postToParent("console", {
|
||||||
|
level,
|
||||||
|
args: args.map((a) => (typeof a === "string" ? a : String(a))),
|
||||||
|
});
|
||||||
|
orig(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const POINTER_PARAM = "pointer";
|
const POINTER_PARAM = "pointer";
|
||||||
const PLAYBACK_PARAM = "playback";
|
const PLAYBACK_PARAM = "playback";
|
||||||
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
||||||
|
|
||||||
const statusEl = document.getElementById("embedStatus");
|
const statusEl = document.getElementById("embedStatus");
|
||||||
const videoEl = document.getElementById("embedVideo");
|
const videoEl = document.getElementById("embedVideo");
|
||||||
const rootEl = document.getElementById("embedRoot");
|
|
||||||
|
|
||||||
const toneClasses = {
|
const toneClasses = {
|
||||||
default: "text-muted",
|
default: "text-muted",
|
||||||
@@ -24,8 +63,7 @@ const setStatus = (message, tone = "default") => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedMessage =
|
const normalizedMessage = typeof message === "string" ? message.trim() : "";
|
||||||
typeof message === "string" ? message.trim() : "";
|
|
||||||
statusEl.textContent = normalizedMessage;
|
statusEl.textContent = normalizedMessage;
|
||||||
|
|
||||||
Object.values(toneClasses).forEach((className) => {
|
Object.values(toneClasses).forEach((className) => {
|
||||||
@@ -40,102 +78,15 @@ const setStatus = (message, tone = "default") => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class EmbedVideoModal {
|
// ModalManager will instantiate EmbedPlayerModal automatically when running in embed mode.
|
||||||
constructor({ root, video, status } = {}) {
|
const app = new Application();
|
||||||
this.root = root || null;
|
|
||||||
this.video = video || null;
|
|
||||||
this.status = status || null;
|
|
||||||
this.eventTarget = new EventTarget();
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(...args) {
|
|
||||||
this.eventTarget.addEventListener(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEventListener(...args) {
|
|
||||||
this.eventTarget.removeEventListener(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchEvent(event) {
|
|
||||||
return this.eventTarget.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRoot() {
|
|
||||||
return this.root;
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideoElement() {
|
|
||||||
return this.video;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVideoElement(videoElement) {
|
|
||||||
if (videoElement instanceof HTMLVideoElement) {
|
|
||||||
this.video = videoElement;
|
|
||||||
}
|
|
||||||
return this.video;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetStats() {
|
|
||||||
// No-op for embed view
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus(message) {
|
|
||||||
setStatus(message, "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLoadingPoster() {
|
|
||||||
setStatus("Preparing playback…", "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
forceRemovePoster() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPosterCleanup() {}
|
|
||||||
|
|
||||||
setTorrentStatsVisibility(isVisible) {
|
|
||||||
if (!this.root) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.root.dataset.torrentStats = isVisible ? "true" : "false";
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
return this.root;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
if (!this.video || !(this.video instanceof HTMLVideoElement)) {
|
|
||||||
throw new Error("Embed video element is missing.");
|
|
||||||
}
|
|
||||||
return this.root;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedModal = new EmbedVideoModal({
|
|
||||||
root: rootEl,
|
|
||||||
video: videoEl,
|
|
||||||
status: statusEl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = new Application({
|
|
||||||
ui: {
|
|
||||||
videoModal: embedModal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
app.showError = (message) => {
|
app.showError = (message) => {
|
||||||
const normalizedMessage =
|
const normalizedMessage = typeof message === "string" ? message.trim() : "";
|
||||||
typeof message === "string" ? message.trim() : "";
|
|
||||||
if (normalizedMessage) {
|
if (normalizedMessage) {
|
||||||
userLogger.error(normalizedMessage);
|
userLogger.error(normalizedMessage);
|
||||||
}
|
}
|
||||||
setStatus(
|
setStatus(normalizedMessage || "Playback failed. Please try again.", "error");
|
||||||
normalizedMessage || "Playback failed. Please try again.",
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
app.showStatus = (message) => {
|
app.showStatus = (message) => {
|
||||||
@@ -228,8 +179,8 @@ const buildRelayList = (relays) => {
|
|||||||
new Set(
|
new Set(
|
||||||
combined
|
combined
|
||||||
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
.map((relay) => (typeof relay === "string" ? relay.trim() : ""))
|
||||||
.filter(Boolean),
|
.filter(Boolean)
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -308,7 +259,7 @@ const resolveEventFromPointer = async (pointer) => {
|
|||||||
if (Array.isArray(events) && events.length) {
|
if (Array.isArray(events) && events.length) {
|
||||||
const flattened = events.flat().filter(Boolean);
|
const flattened = events.flat().filter(Boolean);
|
||||||
rawEvent = flattened.sort(
|
rawEvent = flattened.sort(
|
||||||
(a, b) => (b.created_at || 0) - (a.created_at || 0),
|
(a, b) => (b.created_at || 0) - (a.created_at || 0)
|
||||||
)[0];
|
)[0];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+27
-19
@@ -7,6 +7,7 @@ import { VideoModal } from "./components/VideoModal.js";
|
|||||||
import { RevertModal } from "./components/RevertModal.js";
|
import { RevertModal } from "./components/RevertModal.js";
|
||||||
import { ShareNostrModal } from "./components/ShareNostrModal.js";
|
import { ShareNostrModal } from "./components/ShareNostrModal.js";
|
||||||
import { EmbedVideoModal } from "./components/EmbedVideoModal.js";
|
import { EmbedVideoModal } from "./components/EmbedVideoModal.js";
|
||||||
|
import { EmbedPlayerModal } from "./components/EmbedPlayerModal.js";
|
||||||
import initUploadModal from "./initUploadModal.js";
|
import initUploadModal from "./initUploadModal.js";
|
||||||
import initEditModal from "./initEditModal.js";
|
import initEditModal from "./initEditModal.js";
|
||||||
import initDeleteModal from "./initDeleteModal.js";
|
import initDeleteModal from "./initDeleteModal.js";
|
||||||
@@ -195,29 +196,36 @@ export default class ModalManager {
|
|||||||
});
|
});
|
||||||
app.embedVideoModal = this.embedVideoModal;
|
app.embedVideoModal = this.embedVideoModal;
|
||||||
|
|
||||||
|
const isEmbedMode = typeof window !== "undefined" && window.location.pathname.endsWith("/embed.html");
|
||||||
|
|
||||||
this.videoModal =
|
this.videoModal =
|
||||||
(typeof this.ui.videoModal === "function"
|
(typeof this.ui.videoModal === "function"
|
||||||
? this.ui.videoModal({ app })
|
? this.ui.videoModal({ app })
|
||||||
: this.ui.videoModal) ||
|
: this.ui.videoModal) ||
|
||||||
new VideoModal({
|
(isEmbedMode
|
||||||
removeTrackingScripts,
|
? new EmbedPlayerModal({
|
||||||
setGlobalModalState,
|
document: doc,
|
||||||
document: doc,
|
logger: { log: (...a) => app.log(...a) },
|
||||||
logger: {
|
})
|
||||||
log: (message, ...args) => app.log(message, ...args),
|
: new VideoModal({
|
||||||
},
|
removeTrackingScripts,
|
||||||
mediaLoader: app.mediaLoader,
|
setGlobalModalState,
|
||||||
assets: {
|
document: doc,
|
||||||
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
|
logger: {
|
||||||
},
|
log: (message, ...args) => app.log(message, ...args),
|
||||||
state: {
|
},
|
||||||
loadedThumbnails: app.loadedThumbnails,
|
mediaLoader: app.mediaLoader,
|
||||||
},
|
assets: {
|
||||||
helpers: {
|
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
|
||||||
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
|
},
|
||||||
formatShortNpub: (value) => formatShortNpub(value),
|
state: {
|
||||||
},
|
loadedThumbnails: app.loadedThumbnails,
|
||||||
});
|
},
|
||||||
|
helpers: {
|
||||||
|
safeEncodeNpub: (pubkey) => app.safeEncodeNpub(pubkey),
|
||||||
|
formatShortNpub: (value) => formatShortNpub(value),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.videoModal &&
|
this.videoModal &&
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
export class EmbedPlayerModal {
|
||||||
|
constructor({ document: doc, logger } = {}) {
|
||||||
|
this.document = doc || (typeof document !== "undefined" ? document : null);
|
||||||
|
this.logger = logger || console;
|
||||||
|
this.root = null;
|
||||||
|
this.video = null;
|
||||||
|
this.status = null;
|
||||||
|
this.eventTarget = new EventTarget();
|
||||||
|
|
||||||
|
this.toneClasses = {
|
||||||
|
default: "text-muted",
|
||||||
|
error: "text-danger",
|
||||||
|
success: "text-success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
if (this.document) {
|
||||||
|
this.root = this.document.getElementById("embedRoot");
|
||||||
|
this.video = this.document.getElementById("embedVideo");
|
||||||
|
this.status = this.document.getElementById("embedStatus");
|
||||||
|
}
|
||||||
|
return Promise.resolve(this.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRoot() {
|
||||||
|
return this.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVideoElement() {
|
||||||
|
return this.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoElement(videoElement) {
|
||||||
|
if (videoElement) {
|
||||||
|
this.video = videoElement;
|
||||||
|
}
|
||||||
|
return this.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetStats() {
|
||||||
|
// no-op for embed view
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message, tone = "default") {
|
||||||
|
if (!this.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMessage = typeof message === "string" ? message.trim() : "";
|
||||||
|
this.status.textContent = normalizedMessage;
|
||||||
|
|
||||||
|
Object.values(this.toneClasses).forEach((className) => {
|
||||||
|
if (className) {
|
||||||
|
this.status.classList.remove(className);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneClass = this.toneClasses[tone] || this.toneClasses.default;
|
||||||
|
if (toneClass) {
|
||||||
|
this.status.classList.add(toneClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(message) {
|
||||||
|
this.setStatus(message, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLoadingPoster() {
|
||||||
|
this.setStatus("Preparing playback…", "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
forceRemovePoster() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPosterCleanup() {}
|
||||||
|
|
||||||
|
setTorrentStatsVisibility(isVisible) {
|
||||||
|
if (!this.root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.root.dataset.torrentStats = isVisible ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
return this.root;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {}
|
||||||
|
|
||||||
|
updateMetadata() {}
|
||||||
|
setShareEnabled() {}
|
||||||
|
|
||||||
|
addEventListener(type, listener, options) {
|
||||||
|
this.eventTarget.addEventListener(type, listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type, listener, options) {
|
||||||
|
this.eventTarget.removeEventListener(type, listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(type, detail) {
|
||||||
|
return this.eventTarget.dispatchEvent(new CustomEvent(type, { detail }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { startRelay } from '../../scripts/agent/simple-relay.mjs';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
// Polyfill WebSocket for node environment
|
||||||
|
if (!global.WebSocket) {
|
||||||
|
global.WebSocket = WebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELAY_PORT = 8899;
|
||||||
|
const RELAY_URL = `ws://127.0.0.1:${RELAY_PORT}`;
|
||||||
|
|
||||||
|
test.describe('Embed Player', () => {
|
||||||
|
let relayServer;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
relayServer = startRelay(RELAY_PORT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (relayServer) {
|
||||||
|
await relayServer.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reproduce embed resetStats error', async ({ page }) => {
|
||||||
|
// 1. Create a dummy video event
|
||||||
|
const sk = Uint8Array.from(Buffer.from('7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b', 'hex'));
|
||||||
|
const pk = getPublicKey(sk);
|
||||||
|
|
||||||
|
const event = finalizeEvent({
|
||||||
|
kind: 30078,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', 'test-video'],
|
||||||
|
['title', 'Test Video'],
|
||||||
|
['url', 'https://example.com/video.mp4'],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
pubkey: pk,
|
||||||
|
}, sk);
|
||||||
|
|
||||||
|
// 2. Publish event to local relay
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(RELAY_URL);
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify(['EVENT', event]));
|
||||||
|
});
|
||||||
|
ws.on('message', (msg) => {
|
||||||
|
const data = JSON.parse(msg.toString());
|
||||||
|
if (data[0] === 'OK') {
|
||||||
|
ws.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('error', (e) => {
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Construct nevent
|
||||||
|
const nevent = nip19.neventEncode({
|
||||||
|
id: event.id,
|
||||||
|
relays: [RELAY_URL]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Navigate
|
||||||
|
const consoleMessages = [];
|
||||||
|
page.on('console', msg => consoleMessages.push(msg.text()));
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', err => {
|
||||||
|
errors.push(err);
|
||||||
|
console.log('PAGE ERROR:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force local storage cleanup or setup if needed
|
||||||
|
// The memory said: "Frontend verification scripts using Playwright must inject localStorage.setItem('hasSeenDisclaimer', 'true')"
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('hasSeenDisclaimer', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
const embedUrl = `/embed.html?pointer=${nevent}&playback=url`;
|
||||||
|
await page.goto(embedUrl);
|
||||||
|
|
||||||
|
// 5. Wait for video attempt
|
||||||
|
// We expect the status to change from "Loading video..."
|
||||||
|
// If it crashes, it might not change, or it might change to "Resolving..." then crash.
|
||||||
|
|
||||||
|
// We wait for a bit to let the async playback logic run
|
||||||
|
try {
|
||||||
|
await expect(page.locator('#embedStatus')).not.toHaveText('Loading video…', { timeout: 5000 });
|
||||||
|
await expect(page.locator('#embedStatus')).not.toHaveText('Resolving video…', { timeout: 5000 });
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Timeout waiting for status change. Current status:', await page.locator('#embedStatus').textContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the specific error
|
||||||
|
const typeError = errors.find(e => e.message && e.message.includes('this.videoModal.resetStats is not a function'));
|
||||||
|
if (typeError) {
|
||||||
|
console.error('Reproduced TypeError:', typeError);
|
||||||
|
// Fail the test
|
||||||
|
expect(typeError).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
console.log('No TypeError found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log console for debugging
|
||||||
|
console.log('Console output:', consoleMessages.join('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the logic used in Application.resetTorrentStats.
|
||||||
|
* Note: We test the function logic in isolation because importing the full Application class
|
||||||
|
* triggers side effects (network, webtorrent) that are difficult to mock in this unit test environment.
|
||||||
|
*/
|
||||||
|
function resetTorrentStats(context, logger) {
|
||||||
|
// Exact logic from js/app.js
|
||||||
|
if (context.videoModal) {
|
||||||
|
if (typeof context.videoModal.resetStats === "function") {
|
||||||
|
context.videoModal.resetStats();
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"[Application] resetTorrentStats: videoModal.resetStats not available — skipping."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Application.resetTorrentStats logic', async (t) => {
|
||||||
|
const logger = {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await t.test('does not throw when videoModal is null', () => {
|
||||||
|
const context = { videoModal: null };
|
||||||
|
assert.doesNotThrow(() => resetTorrentStats(context, logger));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('does not throw when videoModal is undefined', () => {
|
||||||
|
const context = { videoModal: undefined };
|
||||||
|
assert.doesNotThrow(() => resetTorrentStats(context, logger));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('does not throw when videoModal lacks resetStats', () => {
|
||||||
|
const context = { videoModal: { other: 1 } };
|
||||||
|
assert.doesNotThrow(() => resetTorrentStats(context, logger));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('calls resetStats when available', () => {
|
||||||
|
let called = false;
|
||||||
|
const context = {
|
||||||
|
videoModal: { resetStats: () => { called = true; } }
|
||||||
|
};
|
||||||
|
resetTorrentStats(context, logger);
|
||||||
|
assert.strictEqual(called, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { EmbedPlayerModal } from '../../js/ui/components/EmbedPlayerModal.js';
|
||||||
|
|
||||||
|
// Polyfill EventTarget for Node < 16 if needed, but likely available
|
||||||
|
if (typeof EventTarget === 'undefined') {
|
||||||
|
global.EventTarget = class EventTarget {
|
||||||
|
constructor() { this.listeners = {}; }
|
||||||
|
addEventListener() {}
|
||||||
|
removeEventListener() {}
|
||||||
|
dispatchEvent() { return true; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('EmbedPlayerModal interface', async (t) => {
|
||||||
|
const modal = new EmbedPlayerModal();
|
||||||
|
|
||||||
|
await t.test('has expected methods', () => {
|
||||||
|
assert.strictEqual(typeof modal.load, 'function');
|
||||||
|
assert.strictEqual(typeof modal.getRoot, 'function');
|
||||||
|
assert.strictEqual(typeof modal.getVideoElement, 'function');
|
||||||
|
assert.strictEqual(typeof modal.setVideoElement, 'function');
|
||||||
|
assert.strictEqual(typeof modal.resetStats, 'function');
|
||||||
|
assert.strictEqual(typeof modal.updateStatus, 'function');
|
||||||
|
assert.strictEqual(typeof modal.setTorrentStatsVisibility, 'function');
|
||||||
|
assert.strictEqual(typeof modal.addEventListener, 'function');
|
||||||
|
assert.strictEqual(typeof modal.removeEventListener, 'function');
|
||||||
|
assert.strictEqual(typeof modal.dispatch, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('resetStats is no-op and does not throw', () => {
|
||||||
|
assert.doesNotThrow(() => modal.resetStats());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user