[ui] Fix DM tab blank shell and harden profile modal tests

This commit is contained in:
thePR0M3TH3AN committed 2026-02-22 20:14:32 -05:00
1 parent ee8569a99b
commit 0af7de6c07
11 files changed
+356 -48

No files matched your search

+50
View File
@@ -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,
+25 -24
View File
@@ -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();
+16 -2
View File
@@ -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 =
+31
View File
@@ -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) => {
+21 -17
View File
@@ -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 ({
+33
View File
@@ -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", () => {
+14 -2
View File
@@ -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);
});