From 0af7de6c079c2051f5dae74ea1f992ddbb7a9c9b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:14:32 -0500 Subject: [PATCH] [ui] Fix DM tab blank shell and harden profile modal tests --- js/testHarness.js | 50 ++++++++ .../ProfileDirectMessageActions.js | 24 +++- .../ProfileDirectMessageHelper.js | 28 +++++ .../ProfileDirectMessageRenderer.js | 2 +- js/ui/profileModalController.js | 49 ++++---- tests/e2e/coverage-ui-runtime.spec.ts | 18 ++- tests/e2e/helpers/bitvidTestFixture.ts | 31 +++++ tests/e2e/notification-system.spec.ts | 38 +++--- tests/e2e/profile-management.spec.ts | 33 +++++ tests/e2e/video-upload-flow.spec.ts | 16 ++- .../profile-direct-message-renderer.test.mjs | 115 ++++++++++++++++++ 11 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 tests/ui/profile-direct-message-renderer.test.mjs diff --git a/js/testHarness.js b/js/testHarness.js index 68a566b2..dad6129d 100644 --- a/js/testHarness.js +++ b/js/testHarness.js @@ -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, diff --git a/js/ui/profileModal/ProfileDirectMessageActions.js b/js/ui/profileModal/ProfileDirectMessageActions.js index ddda863a..9dd5cd4d 100644 --- a/js/ui/profileModal/ProfileDirectMessageActions.js +++ b/js/ui/profileModal/ProfileDirectMessageActions.js @@ -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() { diff --git a/js/ui/profileModal/ProfileDirectMessageHelper.js b/js/ui/profileModal/ProfileDirectMessageHelper.js index be574f64..bbd71102 100644 --- a/js/ui/profileModal/ProfileDirectMessageHelper.js +++ b/js/ui/profileModal/ProfileDirectMessageHelper.js @@ -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) { diff --git a/js/ui/profileModal/ProfileDirectMessageRenderer.js b/js/ui/profileModal/ProfileDirectMessageRenderer.js index 2a905663..7595b1af 100644 --- a/js/ui/profileModal/ProfileDirectMessageRenderer.js +++ b/js/ui/profileModal/ProfileDirectMessageRenderer.js @@ -686,7 +686,7 @@ export class ProfileDirectMessageRenderer { container.textContent = ""; try { - const dmPrivacySettings = this.getDmPrivacySettingsSnapshot(); + const dmPrivacySettings = this.controller.getDmPrivacySettingsSnapshot(); this.dmAppShell = new AppShell({ document, diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index a1801540..7fa3da89 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -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(); diff --git a/tests/e2e/coverage-ui-runtime.spec.ts b/tests/e2e/coverage-ui-runtime.spec.ts index 3b274cc6..1190a3fe 100644 --- a/tests/e2e/coverage-ui-runtime.spec.ts +++ b/tests/e2e/coverage-ui-runtime.spec.ts @@ -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 = diff --git a/tests/e2e/helpers/bitvidTestFixture.ts b/tests/e2e/helpers/bitvidTestFixture.ts index a307b41b..c04df183 100644 --- a/tests/e2e/helpers/bitvidTestFixture.ts +++ b/tests/e2e/helpers/bitvidTestFixture.ts @@ -164,6 +164,7 @@ type BitvidFixtures = { clearRelay: () => Promise; gotoApp: (path?: string) => Promise; loginAs: (page: Page) => Promise; + openProfileModal: (page: Page, pane?: string) => Promise; setTestRelays: (page: Page, relays: string[]) => Promise; setDecryptBehavior: ( page: Page, @@ -247,6 +248,36 @@ export const test = base.extend({ }); }, + 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) => { diff --git a/tests/e2e/notification-system.spec.ts b/tests/e2e/notification-system.spec.ts index b62df3d5..2b46a680 100644 --- a/tests/e2e/notification-system.spec.ts +++ b/tests/e2e/notification-system.spec.ts @@ -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 ({ diff --git a/tests/e2e/profile-management.spec.ts b/tests/e2e/profile-management.spec.ts index 50a15a8c..a73854a2 100644 --- a/tests/e2e/profile-management.spec.ts +++ b/tests/e2e/profile-management.spec.ts @@ -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", () => { diff --git a/tests/e2e/video-upload-flow.spec.ts b/tests/e2e/video-upload-flow.spec.ts index 15ec9e7c..d0d81a5c 100644 --- a/tests/e2e/video-upload-flow.spec.ts +++ b/tests/e2e/video-upload-flow.spec.ts @@ -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"]'); diff --git a/tests/ui/profile-direct-message-renderer.test.mjs b/tests/ui/profile-direct-message-renderer.test.mjs new file mode 100644 index 00000000..6e594340 --- /dev/null +++ b/tests/ui/profile-direct-message-renderer.test.mjs @@ -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( + "
", + { + 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); +});