diff --git a/examples/reproducers/issue-login-modal-leak/README.md b/examples/reproducers/issue-login-modal-leak/README.md new file mode 100644 index 00000000..3de30abd --- /dev/null +++ b/examples/reproducers/issue-login-modal-leak/README.md @@ -0,0 +1,16 @@ +# LoginModalController JSDOM Leak Reproducer + +Reproduction for the JSDOM async activity leak in LoginModalController tests. + +## Usage + +```bash +node examples/reproducers/issue-login-modal-leak/repro.mjs +``` + +## Expected Failure + +The script should fail with an uncaught exception due to async activity after test end: +``` +# Error: Test "LoginModalController shows NIP-46 handshake panel" ... generated asynchronous activity after the test ended. This activity created the error "ReferenceError: HTMLElement is not defined" ... +``` diff --git a/examples/reproducers/issue-login-modal-leak/repro.mjs b/examples/reproducers/issue-login-modal-leak/repro.mjs new file mode 100644 index 00000000..ad05859d --- /dev/null +++ b/examples/reproducers/issue-login-modal-leak/repro.mjs @@ -0,0 +1,175 @@ +import test, { beforeEach, afterEach } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { JSDOM } from 'jsdom'; +import LoginModalController from '../../../js/ui/loginModalController.js'; + +const loginModalHtml = await readFile( + new URL('../../../components/login-modal.html', import.meta.url), + 'utf8', +); + +let dom; +let container; +let controller; + +async function waitForAnimationFrame(window, cycles = 1) { + for (let i = 0; i < cycles; i += 1) { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + } +} + +function createMatchMediaList(query, initialMatches = false) { + let matches = Boolean(initialMatches); + const listeners = new Set(); + + const notify = () => { + const event = { matches, media: query, type: 'change' }; + listeners.forEach((listener) => { + try { + listener(event); + } catch {} + }); + }; + + return { + get matches() { + return matches; + }, + set matches(value) { + matches = Boolean(value); + }, + media: query, + addEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.add(listener); + } + }, + removeEventListener(type, listener) { + if (type === 'change' && typeof listener === 'function') { + listeners.delete(listener); + } + }, + addListener(listener) { + if (typeof listener === 'function') { + listeners.add(listener); + } + }, + removeListener(listener) { + if (typeof listener === 'function') { + listeners.delete(listener); + } + }, + dispatchEvent(event) { + if (!event || event.type !== 'change') { + return true; + } + listeners.forEach((listener) => { + try { + listener(event); + } catch {} + }); + return true; + }, + setMatches(value) { + matches = Boolean(value); + notify(); + }, + }; + } + +beforeEach(() => { + dom = new JSDOM( + '
', + { + url: 'https://example.com', + pretendToBeVisual: true, + }, + ); + + global.window = dom.window; + global.document = dom.window.document; + global.HTMLElement = dom.window.HTMLElement; + global.HTMLButtonElement = dom.window.HTMLButtonElement; + global.HTMLInputElement = dom.window.HTMLInputElement; + global.HTMLTextAreaElement = dom.window.HTMLTextAreaElement; + global.HTMLFormElement = dom.window.HTMLFormElement; + global.HTMLTemplateElement = dom.window.HTMLTemplateElement; + global.Node = dom.window.Node; + global.Element = dom.window.Element; + global.MutationObserver = dom.window.MutationObserver; + + window.requestAnimationFrame = (callback) => callback(0); + global.requestAnimationFrame = window.requestAnimationFrame; + + window.__matchMediaMocks = []; + window.matchMedia = (query) => { + const mql = createMatchMediaList(query, false); + window.__matchMediaMocks.push(mql); + return mql; + }; + global.matchMedia = window.matchMedia; + + container = document.getElementById('modalMount'); + container.innerHTML = loginModalHtml; +}); + +afterEach(() => { + if (controller) { + controller.destroy(); + controller = null; + } + + delete global.window; + delete global.document; + delete global.HTMLElement; + delete global.HTMLButtonElement; + delete global.HTMLInputElement; + delete global.HTMLTextAreaElement; + delete global.HTMLFormElement; + delete global.HTMLTemplateElement; + delete global.Node; + delete global.Element; + delete global.MutationObserver; + delete global.requestAnimationFrame; + delete global.matchMedia; + + if (dom) { + dom.window.close(); + dom = null; + } +}); + +function createController(options = {}) { + const modalElement = container.querySelector('#loginModal'); + return new LoginModalController({ + modalElement, + providers: options.providers || [], + services: options.services || {}, + callbacks: options.callbacks || {}, + }); +} + +test('LoginModalController shows NIP-46 handshake panel', async (t) => { + const providers = [{ id: 'nip46', label: 'Remote Signer', login: async () => {} }]; + const authService = { requestLogin: async () => {} }; + controller = createController({ providers, services: { authService } }); + controller.initialize(); + + const providerButton = container.querySelector('[data-provider-id="nip46"]'); + assert.ok(providerButton, 'nip46 provider button should be present'); + + // Trigger click on provider + const clickEvent = new dom.window.Event('click', { bubbles: true }); + providerButton.dispatchEvent(clickEvent); + + // Wait for the nip46 form to appear + await waitForAnimationFrame(window, 5); + + const form = container.querySelector('[data-nip46-form]'); + assert.ok(form, 'nip46 form should be present'); + + const handshakePanel = form.querySelector('[data-nip46-handshake-panel]'); + assert.ok(handshakePanel, 'handshake panel should be present'); + assert.ok(!handshakePanel.classList.contains('hidden'), 'handshake panel should not be hidden'); +}); diff --git a/examples/reproducers/issue-moderation-block-state/README.md b/examples/reproducers/issue-moderation-block-state/README.md new file mode 100644 index 00000000..b21fd84c --- /dev/null +++ b/examples/reproducers/issue-moderation-block-state/README.md @@ -0,0 +1,21 @@ +# VideoCard Block State Reproducer + +Reproduction for the bug where VideoCard block action fails to restore trusted mute hide state after override. + +## Usage + +```bash +node examples/reproducers/issue-moderation-block-state/repro.mjs +``` + +## Expected Failure + +The script should fail with an assertion error: +``` +not ok 1 - VideoCard block action restores trusted mute hide state after override +... + error: |- + Expected values to be strictly equal: + + false !== true +``` diff --git a/examples/reproducers/issue-moderation-block-state/repro.mjs b/examples/reproducers/issue-moderation-block-state/repro.mjs new file mode 100644 index 00000000..890c48fd --- /dev/null +++ b/examples/reproducers/issue-moderation-block-state/repro.mjs @@ -0,0 +1,198 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import "../../../tests/test-helpers/setup-localstorage.mjs"; +import { VideoCard } from "../../../js/ui/components/VideoCard.js"; +import { clearModerationOverride, setModerationOverride } from "../../../js/state/cache.js"; +import { + withMockedNostrTools, + createModerationAppHarness, +} from "../../../tests/helpers/moderation-test-helpers.mjs"; +import { + applyModerationContextDatasets, + normalizeVideoModerationContext, +} from "../../../js/ui/moderationUiHelpers.js"; +import { userBlocks } from "../../../js/userBlocks.js"; + +function setupDom(t) { + const dom = new JSDOM("", { + url: "https://example.com", + pretendToBeVisual: true, + }); + + const { window } = dom; + const previous = new Map(); + const keys = [ + "window", + "document", + "HTMLElement", + "Element", + "Node", + "MouseEvent", + "CustomEvent", + "ResizeObserver", + "requestAnimationFrame", + "cancelAnimationFrame", + ]; + + keys.forEach((key) => { + const hadValue = Object.prototype.hasOwnProperty.call(globalThis, key); + previous.set(key, hadValue ? globalThis[key] : Symbol.for("__undefined__")); + if (key === "requestAnimationFrame" && typeof window[key] !== "function") { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + } else if (key === "cancelAnimationFrame" && typeof window[key] !== "function") { + window.cancelAnimationFrame = (id) => clearTimeout(id); + } else if (key === "ResizeObserver" && typeof window[key] !== "function") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + globalThis[key] = window[key]; + }); + + // Mock navigator safely + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, "navigator", { + value: window.navigator, + writable: true, + configurable: true, + }); + + if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + globalThis.requestAnimationFrame = window.requestAnimationFrame; + } + + if (!window.cancelAnimationFrame) { + window.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame; + } + + if (!window.ResizeObserver) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + window.ResizeObserver = ResizeObserverStub; + globalThis.ResizeObserver = ResizeObserverStub; + } + + t.after(() => { + dom.window.close(); + keys.forEach((key) => { + const previousValue = previous.get(key); + if (previousValue === Symbol.for("__undefined__")) { + delete globalThis[key]; + } else { + globalThis[key] = previousValue; + } + }); + // Restore navigator + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + return { window, document: window.document }; +} + +test("VideoCard block action restores trusted mute hide state after override", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + app.getActiveModerationThresholds = () => ({ + autoplayBlockThreshold: Number.POSITIVE_INFINITY, + blurThreshold: Number.POSITIVE_INFINITY, + trustedMuteHideThreshold: 1, + trustedSpamHideThreshold: Number.POSITIVE_INFINITY, + }); + app.pubkey = "f".repeat(64); + app.isUserLoggedIn = () => true; + app.showStatus = () => {}; + app.showError = () => {}; + app.onVideosShouldRefresh = async () => {}; + + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalAddBlock = userBlocks.addBlock; + const originalIsBlocked = userBlocks.isBlocked; + + userBlocks.ensureLoaded = async () => {}; + userBlocks.addBlock = async () => ({ ok: true }); + userBlocks.isBlocked = () => false; + + t.after(() => { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.addBlock = originalAddBlock; + userBlocks.isBlocked = originalIsBlocked; + }); + + const videoId = "d".repeat(64); + const video = { + id: videoId, + title: "Muted Hide Clip", + pubkey: "e".repeat(64), + moderation: { + trustedMuted: true, + trustedMuteCount: 1, + trustedMuters: ["f".repeat(64)], + trustedCount: 0, + reportType: "nudity", + }, + }; + + app.videosMap.set(video.id, video); + app.currentVideo = video; + app.decorateVideoModeration(video); + + const card = new VideoCard({ + document, + video, + formatters: { + formatTimeAgo: () => "moments ago", + }, + helpers: { + isMagnetSupported: () => false, + }, + }); + + document.body.appendChild(card.getRoot()); + + assert.equal( + normalizeVideoModerationContext(card.video?.moderation).activeHidden, + true, + ); + assert.equal(card.getRoot().dataset.moderationHidden, "true"); + + card.onModerationOverride = ({ video: overrideVideo, card: overrideCard }) => + app.handleModerationOverride({ video: overrideVideo, card: overrideCard }); + + card.moderationActionButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterOverride = normalizeVideoModerationContext( + card.video?.moderation, + ); + assert.equal(contextAfterOverride.overrideActive, true); + assert.equal(contextAfterOverride.activeHidden, false); + assert.ok(card.moderationBlockButton); + + card.onModerationBlock = ({ video: targetVideo, card: targetCard }) => + app.handleModerationBlock({ video: targetVideo, card: targetCard }); + + card.moderationBlockButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterHide = normalizeVideoModerationContext(card.video?.moderation); + // This is where it fails in the original test + assert.equal(contextAfterHide.overrideActive, false, "Override should be cleared after block"); + assert.equal(contextAfterHide.activeHidden, true, "Content should be hidden again after block"); + assert.equal(card.getRoot().dataset.moderationHidden, "true", "Card should have hidden dataset"); + assert.equal(card.moderationBlockButton, null, "Block button should be gone"); +}); diff --git a/examples/reproducers/issue-popover-flip/README.md b/examples/reproducers/issue-popover-flip/README.md new file mode 100644 index 00000000..17995c54 --- /dev/null +++ b/examples/reproducers/issue-popover-flip/README.md @@ -0,0 +1,27 @@ +# Popover Flip Layout Reproducer + +Reproduction for the visual regression layout issue where the popover fails to flip to the top in a restricted viewport. + +## Prerequisites + +You must build the CSS assets before running the test: + +```bash +npm run build:css +``` + +## Usage + +```bash +npx playwright test -c examples/reproducers/issue-popover-flip/playwright.config.ts +``` + +## Expected Failure + +The test should fail asserting that the popover is within the viewport (it fails to flip to top, so it overflows bottom): +``` +Error: expect(received).toBeLessThanOrEqual(expected) + +Expected: <= 250.5 +Received: 311.625 +``` diff --git a/examples/reproducers/issue-popover-flip/playwright.config.ts b/examples/reproducers/issue-popover-flip/playwright.config.ts new file mode 100644 index 00000000..b0fcb89e --- /dev/null +++ b/examples/reproducers/issue-popover-flip/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + use: { + baseURL: "http://127.0.0.1:4173", + }, + webServer: { + command: "npx http-server ../../.. -p 4173 -a 127.0.0.1 -c-1 --silent", + url: "http://127.0.0.1:4173", + reuseExistingServer: true, + timeout: 120_000, + }, +}); diff --git a/examples/reproducers/issue-popover-flip/repro.spec.ts b/examples/reproducers/issue-popover-flip/repro.spec.ts new file mode 100644 index 00000000..7712111c --- /dev/null +++ b/examples/reproducers/issue-popover-flip/repro.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; +import { + getPanelMetrics, + getPanelWithTriggerMetrics, +} from "../../../tests/e2e/helpers/popoverUtils"; +import { + applyReducedMotion, + ensureTestAssets, + failOnConsoleErrors, + waitForRAF, +} from "../../../tests/e2e/helpers/uiTestUtils"; + +function assertWithinViewport(metrics: { + rect: { top: number; left: number; right: number; bottom: number }; + viewport: { width: number; height: number }; +}) { + const tolerance = 0.5; + expect(metrics.rect.left).toBeGreaterThanOrEqual(-tolerance); + expect(metrics.rect.top).toBeGreaterThanOrEqual(-tolerance); + expect(metrics.rect.right).toBeLessThanOrEqual(metrics.viewport.width + tolerance); + expect(metrics.rect.bottom).toBeLessThanOrEqual(metrics.viewport.height + tolerance); +} + +test.describe("popover layout scenarios", () => { + test.beforeEach(async ({ page }) => { + await ensureTestAssets(page); + await applyReducedMotion(page); + failOnConsoleErrors(page); + await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" }); + }); + + // flaky: Popover fails to flip to top in restricted viewport (floating-ui issue?) + test("keeps the bottom-right grid menu inside the viewport", async ({ page }) => { + // Force a tall body to ensure there is space for the popover to flip to the top + await page.addStyleTag({ content: "body { min-height: 2000px !important; }" }); + // Reduce viewport height to force the menu to flip to the top + await page.setViewportSize({ width: 1280, height: 250 }); + + const trigger = page.locator('[data-test-trigger="grid-bottom-right"]'); + // Scroll to bottom to ensure the element is near the viewport edge + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await trigger.click(); + const panel = page.locator('[data-test-panel="grid-bottom-right"]'); + await expect(panel).toHaveAttribute("data-popover-state", "open"); + + const metrics = await getPanelMetrics(panel); + + assertWithinViewport(metrics); + expect(metrics.state).toBe("open"); + expect(metrics.placement.startsWith("top"), `Expected placement to start with 'top' but got '${metrics.placement}'`).toBeTruthy(); + expect(metrics.popoverMaxWidth).toBe(metrics.tokenMaxWidth); + expect(metrics.tokenMaxWidth.length).toBeGreaterThan(0); + }); +});