mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[ui] Fix DM tab blank shell and harden profile modal tests
This commit is contained in:
1 parent
ee8569a99b
commit
0af7de6c07
11 files changed
+356
-48
No files matched your search
@@ -623,6 +623,55 @@ async function openUploadModal() {
|
||||
return { ok: false, reason: "upload-unavailable" };
|
||||
}
|
||||
|
||||
async function openProfileModal(pane = "account") {
|
||||
const app = getApplication();
|
||||
const normalizedPane =
|
||||
typeof pane === "string" && pane.trim() ? pane.trim().toLowerCase() : "account";
|
||||
|
||||
const ensureProfileModalLoaded = async () => {
|
||||
if (!app?.profileController) {
|
||||
return false;
|
||||
}
|
||||
if (document.getElementById("profileModal")) {
|
||||
return true;
|
||||
}
|
||||
if (typeof app.profileController.load === "function") {
|
||||
await app.profileController.load();
|
||||
return Boolean(document.getElementById("profileModal"));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (app?.profileController && typeof app.profileController.show === "function") {
|
||||
await ensureProfileModalLoaded();
|
||||
await app.profileController.show(normalizedPane);
|
||||
try {
|
||||
await waitForModalOpen("profileModal", 15000);
|
||||
return { ok: true, source: "controller", pane: normalizedPane };
|
||||
} catch (_error) {
|
||||
// Fall through to click-based fallback.
|
||||
}
|
||||
}
|
||||
|
||||
const profileButton = document.querySelector('[data-testid="profile-button"]');
|
||||
if (profileButton instanceof HTMLElement) {
|
||||
profileButton.click();
|
||||
await waitForModalOpen("profileModal", 15000);
|
||||
|
||||
if (
|
||||
normalizedPane !== "account" &&
|
||||
app?.profileController &&
|
||||
typeof app.profileController.selectPane === "function"
|
||||
) {
|
||||
app.profileController.selectPane(normalizedPane);
|
||||
}
|
||||
|
||||
return { ok: true, source: "click", pane: normalizedPane };
|
||||
}
|
||||
|
||||
return { ok: false, reason: "profile-unavailable", pane: normalizedPane };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relay connection health info.
|
||||
*/
|
||||
@@ -704,6 +753,7 @@ export function installTestHarness() {
|
||||
waitForModalOpen,
|
||||
openLoginModal,
|
||||
openUploadModal,
|
||||
openProfileModal,
|
||||
getRelayHealth,
|
||||
applyRelayOverrides,
|
||||
setTestRelays,
|
||||
|
||||
@@ -14,6 +14,26 @@ export class ProfileDirectMessageActions {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
getDmPrivacySettingsSnapshot(...args) {
|
||||
return this.controller.getDmPrivacySettingsSnapshot(...args);
|
||||
}
|
||||
|
||||
persistDmPrivacySettings(...args) {
|
||||
return this.controller.persistDmPrivacySettings(...args);
|
||||
}
|
||||
|
||||
syncDmPrivacySettingsUi(...args) {
|
||||
return this.controller.syncDmPrivacySettingsUi(...args);
|
||||
}
|
||||
|
||||
focusMessageComposer(...args) {
|
||||
return this.controller.focusMessageComposer(...args);
|
||||
}
|
||||
|
||||
setDirectMessageRecipient(...args) {
|
||||
return this.controller.setDirectMessageRecipient(...args);
|
||||
}
|
||||
|
||||
handleReadReceiptsToggle(enabled) {
|
||||
this.persistDmPrivacySettings({
|
||||
readReceiptsEnabled: Boolean(enabled),
|
||||
@@ -819,12 +839,12 @@ export class ProfileDirectMessageActions {
|
||||
}
|
||||
|
||||
setDmRelayPreferencesStatus(message = "") {
|
||||
if (!(this.profileMessagesRelayStatus instanceof HTMLElement)) {
|
||||
if (!(this.controller.renderer.profileMessagesRelayStatus instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = typeof message === "string" ? message.trim() : "";
|
||||
this.profileMessagesRelayStatus.textContent = text;
|
||||
this.controller.renderer.profileMessagesRelayStatus.textContent = text;
|
||||
}
|
||||
|
||||
populateDmRelayPreferences() {
|
||||
|
||||
@@ -10,6 +10,34 @@ export class ProfileDirectMessageHelper {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
updateDmPrivacyToggleForRecipient(...args) {
|
||||
if (typeof this.mainController.updateDmPrivacyToggleForRecipient === "function") {
|
||||
return this.mainController.updateDmPrivacyToggleForRecipient(...args);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
resolveDirectMessageScheme(message) {
|
||||
if (typeof this.mainController.resolveDirectMessageScheme === "function") {
|
||||
return this.mainController.resolveDirectMessageScheme(message);
|
||||
}
|
||||
|
||||
if (!message || typeof message !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const scheme =
|
||||
typeof message.scheme === "string"
|
||||
? message.scheme
|
||||
: typeof message.encryption_scheme === "string"
|
||||
? message.encryption_scheme
|
||||
: typeof message?.decryptor?.scheme === "string"
|
||||
? message.decryptor.scheme
|
||||
: "";
|
||||
|
||||
return typeof scheme === "string" ? scheme.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
resolveActiveDmActor() {
|
||||
const active = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey());
|
||||
if (active) {
|
||||
|
||||
@@ -686,7 +686,7 @@ export class ProfileDirectMessageRenderer {
|
||||
container.textContent = "";
|
||||
|
||||
try {
|
||||
const dmPrivacySettings = this.getDmPrivacySettingsSnapshot();
|
||||
const dmPrivacySettings = this.controller.getDmPrivacySettingsSnapshot();
|
||||
|
||||
this.dmAppShell = new AppShell({
|
||||
document,
|
||||
|
||||
@@ -730,55 +730,56 @@ export class ProfileModalController {
|
||||
|
||||
this.blockListController.cacheDomReferences();
|
||||
|
||||
this.dmController.renderer.cacheDomReferences();
|
||||
this.dmController.profileMessagesPane =
|
||||
document.getElementById("profilePaneMessages") || null;
|
||||
this.dmController.renderer.profileMessagesPane;
|
||||
this.dmController.profileMessagesList =
|
||||
document.getElementById("profileMessagesList") || null;
|
||||
this.dmController.renderer.profileMessagesList;
|
||||
this.dmController.profileMessagesEmpty =
|
||||
document.getElementById("profileMessagesEmpty") || null;
|
||||
this.dmController.renderer.profileMessagesEmpty;
|
||||
this.dmController.profileMessagesLoading =
|
||||
document.getElementById("profileMessagesLoading") || null;
|
||||
this.dmController.renderer.profileMessagesLoading;
|
||||
this.dmController.profileMessagesError =
|
||||
document.getElementById("profileMessagesError") || null;
|
||||
this.dmController.renderer.profileMessagesError;
|
||||
this.dmController.profileMessagesStatus =
|
||||
document.getElementById("profileMessagesStatus") || null;
|
||||
this.dmController.renderer.profileMessagesStatus;
|
||||
this.dmController.profileMessagesReloadButton =
|
||||
document.getElementById("profileMessagesReload") || null;
|
||||
this.dmController.renderer.profileMessagesReloadButton;
|
||||
this.dmController.profileMessagesConversation =
|
||||
document.getElementById("profileMessagesConversation") || null;
|
||||
this.dmController.renderer.profileMessagesConversation;
|
||||
this.dmController.profileMessagesConversationEmpty =
|
||||
document.getElementById("profileMessagesConversationEmpty") || null;
|
||||
this.dmController.renderer.profileMessagesConversationEmpty;
|
||||
this.dmController.profileMessageInput =
|
||||
document.getElementById("profileMessageInput") || null;
|
||||
this.dmController.renderer.profileMessageInput;
|
||||
this.dmController.profileMessageSendButton =
|
||||
document.getElementById("profileMessageSendBtn") || null;
|
||||
this.dmController.renderer.profileMessageSendButton;
|
||||
this.dmController.profileMessageAttachmentInput =
|
||||
document.getElementById("profileMessageAttachmentInput") || null;
|
||||
this.dmController.renderer.profileMessageAttachmentInput;
|
||||
this.dmController.profileMessageAttachmentButton =
|
||||
document.getElementById("profileMessageAttachmentButton") || null;
|
||||
this.dmController.renderer.profileMessageAttachmentButton;
|
||||
this.dmController.profileMessageAttachmentEncrypt =
|
||||
document.getElementById("profileMessageAttachmentEncrypt") || null;
|
||||
this.dmController.renderer.profileMessageAttachmentEncrypt;
|
||||
this.dmController.profileMessageAttachmentList =
|
||||
document.getElementById("profileMessageAttachmentList") || null;
|
||||
this.dmController.renderer.profileMessageAttachmentList;
|
||||
this.dmController.profileMessageAttachmentClearCache =
|
||||
document.getElementById("profileMessageAttachmentClearCache") || null;
|
||||
this.dmController.renderer.profileMessageAttachmentClearCache;
|
||||
this.dmController.profileMessagesComposerHelper =
|
||||
document.getElementById("profileMessagesComposerHelper") || null;
|
||||
this.dmController.renderer.profileMessagesComposerHelper;
|
||||
this.dmController.profileMessagesSendDmButton =
|
||||
document.getElementById("profileMessagesSendDm") || null;
|
||||
this.dmController.renderer.profileMessagesSendDmButton;
|
||||
this.dmController.profileMessagesOpenRelaysButton =
|
||||
document.getElementById("profileMessagesOpenRelays") || null;
|
||||
this.dmController.renderer.profileMessagesOpenRelaysButton;
|
||||
this.dmController.profileMessagesPrivacyToggle =
|
||||
document.getElementById("profileMessagesPrivacyToggle") || null;
|
||||
this.dmController.renderer.profileMessagesPrivacyToggle;
|
||||
this.dmController.profileMessagesPrivacyMode =
|
||||
document.getElementById("profileMessagesPrivacyMode") || null;
|
||||
this.dmController.renderer.profileMessagesPrivacyMode;
|
||||
this.cacheDmRelayElements();
|
||||
this.dmController.profileMessagesUnreadDot =
|
||||
document.getElementById("profileMessagesUnreadDot") || null;
|
||||
this.dmController.renderer.profileMessagesUnreadDot;
|
||||
this.dmController.dmAppShellContainer =
|
||||
document.getElementById("dmAppShellMount") || null;
|
||||
this.dmController.renderer.dmAppShellContainer;
|
||||
this.dmController.profileLinkPreviewAutoToggle =
|
||||
document.getElementById("profileLinkPreviewAutoToggle") || null;
|
||||
this.dmController.renderer.profileLinkPreviewAutoToggle;
|
||||
|
||||
this.walletController.cacheDomReferences();
|
||||
this.storageController.cacheDomReferences();
|
||||
|
||||
@@ -243,8 +243,22 @@ test.describe("runtime UI utility coverage", () => {
|
||||
|
||||
window.location.hash = "#view=docs&doc=advanced";
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const waitForCondition = async (predicate: () => boolean, timeoutMs = 3000) => {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (predicate()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
await waitForCondition(() => {
|
||||
const html = document.getElementById("markdown-container")?.innerHTML || "";
|
||||
return document.title === "Advanced | bitvid docs" && html.includes("Second doc");
|
||||
});
|
||||
|
||||
const advancedTitle = document.title;
|
||||
const markdownHtml =
|
||||
|
||||
@@ -164,6 +164,7 @@ type BitvidFixtures = {
|
||||
clearRelay: () => Promise<void>;
|
||||
gotoApp: (path?: string) => Promise<void>;
|
||||
loginAs: (page: Page) => Promise<string>;
|
||||
openProfileModal: (page: Page, pane?: string) => Promise<any>;
|
||||
setTestRelays: (page: Page, relays: string[]) => Promise<any>;
|
||||
setDecryptBehavior: (
|
||||
page: Page,
|
||||
@@ -247,6 +248,36 @@ export const test = base.extend<BitvidFixtures>({
|
||||
});
|
||||
},
|
||||
|
||||
openProfileModal: async ({}, use) => {
|
||||
await use(async (page: Page, pane = "account") => {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const result = await page.evaluate(async (targetPane) => {
|
||||
const harness = (window as any).__bitvidTest__;
|
||||
if (!harness || typeof harness.openProfileModal !== "function") {
|
||||
throw new Error("openProfileModal is not available in test harness");
|
||||
}
|
||||
return harness.openProfileModal(targetPane);
|
||||
}, pane);
|
||||
if (result?.ok) {
|
||||
return result;
|
||||
}
|
||||
lastError = new Error(
|
||||
`openProfileModal returned non-ok result: ${JSON.stringify(result)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("openProfileModal failed after retries");
|
||||
});
|
||||
},
|
||||
|
||||
setTestRelays: async ({}, use) => {
|
||||
await use(async (page: Page, relays: string[]) => {
|
||||
return page.evaluate((urls) => {
|
||||
|
||||
@@ -144,33 +144,37 @@ test.describe("Notification system — behavior", () => {
|
||||
}) => {
|
||||
// Given: the app is loaded
|
||||
await gotoApp();
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
// When: a status notification is triggered
|
||||
await page.evaluate(() => {
|
||||
const snapshot = await page.evaluate(async () => {
|
||||
const { getApplication } = await import("/js/applicationContext.js");
|
||||
const app = getApplication();
|
||||
const statusContainer = document.getElementById("statusContainer");
|
||||
const messageSpan = statusContainer?.querySelector(
|
||||
"[data-status-message]",
|
||||
);
|
||||
const portal = document.getElementById("notificationPortal");
|
||||
if (statusContainer) {
|
||||
if (messageSpan instanceof HTMLElement) {
|
||||
messageSpan.textContent = "Loading data...";
|
||||
} else {
|
||||
statusContainer.textContent = "Loading data...";
|
||||
}
|
||||
statusContainer.classList.remove("hidden");
|
||||
}
|
||||
if (portal) {
|
||||
portal.classList.add("notification-portal--active");
|
||||
|
||||
if (app && typeof app.showStatus === "function") {
|
||||
app.showStatus("Loading data...", { showSpinner: true });
|
||||
}
|
||||
|
||||
return {
|
||||
hidden: statusContainer?.classList.contains("hidden") ?? true,
|
||||
message:
|
||||
messageSpan instanceof HTMLElement
|
||||
? messageSpan.textContent
|
||||
: statusContainer?.textContent || "",
|
||||
portalActive:
|
||||
portal?.classList.contains("notification-portal--active") ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
// Then: the status container should be visible with the message
|
||||
const statusContainer = page.locator("#statusContainer");
|
||||
await expect(statusContainer).not.toHaveClass(/hidden/);
|
||||
|
||||
const messageSpan = statusContainer.locator("[data-status-message]");
|
||||
await expect(messageSpan).toHaveText("Loading data...");
|
||||
// Then: status state should reflect an active status message.
|
||||
expect(snapshot.hidden).toBe(false);
|
||||
expect(snapshot.message).toContain("Loading data...");
|
||||
expect(snapshot.portalActive).toBe(true);
|
||||
});
|
||||
|
||||
test("portal active class is absent when all banners hidden", async ({
|
||||
|
||||
@@ -45,6 +45,39 @@ test.describe("Profile management and moderation", () => {
|
||||
// Profile button may or may not be visible depending on layout;
|
||||
// at minimum it should be attached and not hidden
|
||||
});
|
||||
|
||||
test("DM tab mounts and remounts the direct message shell", async ({
|
||||
page,
|
||||
gotoApp,
|
||||
loginAs,
|
||||
openProfileModal,
|
||||
}) => {
|
||||
await gotoApp();
|
||||
await loginAs(page);
|
||||
await page.waitForFunction(
|
||||
() => (window as any).__bitvidTest__?.getAppState?.().isLoggedIn === true,
|
||||
);
|
||||
await expect(page.locator('[data-testid="profile-button"]')).toBeAttached();
|
||||
|
||||
const openResult = await openProfileModal(page, "account");
|
||||
expect(openResult.ok).toBe(true);
|
||||
|
||||
await expect(page.locator("#profileModal")).toBeVisible();
|
||||
await page.locator("#profileNavMessages").click();
|
||||
|
||||
const dmPane = page.locator("#profilePaneMessages");
|
||||
await expect(dmPane).toBeVisible();
|
||||
await expect(page.locator("#dmAppShellMount .dm-app-shell")).toHaveCount(1);
|
||||
await expect(page.locator("#dmAppShellMount .dm-app-shell__sidebar")).toHaveCount(1);
|
||||
await expect(page.locator("#dmAppShellMount .dm-app-shell__main")).toHaveCount(1);
|
||||
|
||||
// Switching away and back should not leave the DM pane blank.
|
||||
await page.locator("#profileNavAccount").click();
|
||||
await expect(page.locator("#profilePaneAccount")).toBeVisible();
|
||||
await page.locator("#profileNavMessages").click();
|
||||
await expect(dmPane).toBeVisible();
|
||||
await expect(page.locator("#dmAppShellMount .dm-app-shell")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Decrypt behavior simulation", () => {
|
||||
|
||||
@@ -23,11 +23,23 @@ test.describe("Video upload and discovery", () => {
|
||||
// Given: a logged-in user on the app
|
||||
await gotoApp();
|
||||
await loginAs(page);
|
||||
await page.waitForFunction(
|
||||
() => (window as any).__bitvidTest__?.getAppState?.().isLoggedIn === true,
|
||||
);
|
||||
|
||||
// When: user clicks the upload button
|
||||
// When: user opens upload modal
|
||||
const uploadBtn = page.locator('[data-testid="upload-button"]');
|
||||
await expect(uploadBtn).toBeVisible();
|
||||
await uploadBtn.click();
|
||||
const openResult = await page.evaluate(async () => {
|
||||
const harness = (window as any).__bitvidTest__;
|
||||
if (!harness || typeof harness.openUploadModal !== "function") {
|
||||
return { ok: false, reason: "harness-unavailable" };
|
||||
}
|
||||
return harness.openUploadModal();
|
||||
});
|
||||
if (!openResult?.ok) {
|
||||
await uploadBtn.click();
|
||||
}
|
||||
|
||||
// Then: upload modal becomes visible with expected form fields
|
||||
const modal = page.locator('[data-testid="upload-modal"]');
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import test, { beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { ProfileDirectMessageRenderer } from "../../js/ui/profileModal/ProfileDirectMessageRenderer.js";
|
||||
|
||||
let dom;
|
||||
let windowRef;
|
||||
let documentRef;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
"<!DOCTYPE html><html><body><div id=\"dmAppShellMount\"></div></body></html>",
|
||||
{
|
||||
pretendToBeVisual: true,
|
||||
url: "https://example.com",
|
||||
},
|
||||
);
|
||||
|
||||
windowRef = dom.window;
|
||||
documentRef = windowRef.document;
|
||||
global.window = windowRef;
|
||||
global.document = documentRef;
|
||||
global.HTMLElement = windowRef.HTMLElement;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (dom) {
|
||||
dom.window.close();
|
||||
}
|
||||
dom = null;
|
||||
windowRef = null;
|
||||
documentRef = null;
|
||||
delete global.window;
|
||||
delete global.document;
|
||||
delete global.HTMLElement;
|
||||
});
|
||||
|
||||
test("renderDmAppShell mounts the DM shell into the messages pane", async () => {
|
||||
let privacySnapshotCalls = 0;
|
||||
|
||||
const mainController = {
|
||||
normalizeHexPubkey: (value) => (typeof value === "string" ? value.trim() : ""),
|
||||
services: {
|
||||
nostrClient: {},
|
||||
},
|
||||
nostrService: {
|
||||
acknowledgeRenderedDirectMessages: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
const controller = {
|
||||
directMessagesCache: [],
|
||||
messagesLoadingState: "ready",
|
||||
dmComposerState: "idle",
|
||||
dmMobileView: "list",
|
||||
helper: {
|
||||
async buildDmConversationData() {
|
||||
return {
|
||||
actor: "a".repeat(64),
|
||||
conversations: [
|
||||
{
|
||||
id: "dm:demo",
|
||||
name: "Demo",
|
||||
preview: "Hello",
|
||||
timestamp: "now",
|
||||
unreadCount: 0,
|
||||
avatarSrc: "",
|
||||
status: "",
|
||||
pubkey: "b".repeat(64),
|
||||
lightningAddress: "",
|
||||
relayHints: [],
|
||||
},
|
||||
],
|
||||
activeConversationId: "dm:demo",
|
||||
activeThread: {
|
||||
remoteHex: "b".repeat(64),
|
||||
latestMessage: { scheme: "nip04" },
|
||||
},
|
||||
timeline: [{ id: "1", direction: "incoming", body: "Hello", timestamp: "09:00" }],
|
||||
};
|
||||
},
|
||||
resolveActiveDmRecipient: () => "b".repeat(64),
|
||||
resolveConversationPrivacyMode: () => "nip04",
|
||||
resolveProfileSummaryForPubkey: () => ({ avatarSrc: "" }),
|
||||
resolveActiveDmActor: () => "a".repeat(64),
|
||||
getLatestDirectMessageTimestampForConversation: () => 1,
|
||||
},
|
||||
setFocusedDmConversation: () => {},
|
||||
setDirectMessageRecipient: () => {},
|
||||
populateProfileMessages: () => {},
|
||||
handleDmConversationSelect: () => {},
|
||||
handleDmAppShellSendMessage: () => {},
|
||||
handleDmConversationMarkRead: () => {},
|
||||
handleDmMarkAllConversationsRead: () => {},
|
||||
handleReadReceiptsToggle: () => {},
|
||||
handleTypingIndicatorsToggle: () => {},
|
||||
openDmSettingsModal: () => {},
|
||||
getDmPrivacySettingsSnapshot: () => {
|
||||
privacySnapshotCalls += 1;
|
||||
return { readReceiptsEnabled: false, typingIndicatorsEnabled: false };
|
||||
},
|
||||
};
|
||||
|
||||
const renderer = new ProfileDirectMessageRenderer(mainController, controller);
|
||||
controller.renderer = renderer;
|
||||
renderer.cacheDomReferences();
|
||||
|
||||
await renderer.renderDmAppShell([], { actorPubkey: "a".repeat(64) });
|
||||
|
||||
const mount = documentRef.getElementById("dmAppShellMount");
|
||||
assert.ok(mount);
|
||||
assert.ok(mount.querySelector(".dm-app-shell"));
|
||||
assert.equal(privacySnapshotCalls, 1);
|
||||
});
|
||||
Reference in new issue
Block a user