diff --git a/examples/reproducers/popover-flip-bug/README.md b/examples/reproducers/popover-flip-bug/README.md new file mode 100644 index 00000000..01ff5e52 --- /dev/null +++ b/examples/reproducers/popover-flip-bug/README.md @@ -0,0 +1,27 @@ +# Popover Flip Bug Reproducer + +This reproducer demonstrates a bug where the popover fails to flip to the top when the bottom viewport space is restricted. + +## Running the Reproducer + +1. Ensure dependencies are installed: + ```bash + npm ci + npx playwright install chromium + ``` + +2. Build the CSS (required for the test page): + ```bash + npm run build:css + ``` + +3. Run the Playwright test using the reproducer config: + ```bash + npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/popover-flip-bug/repro.spec.ts + ``` + +## Expected Behavior + +The test should FAIL with an assertion error similar to: +`Error: expect(received).toBeLessThanOrEqual(expected)` +indicating that the popover's bottom coordinate is outside the viewport. diff --git a/examples/reproducers/popover-flip-bug/repro.spec.ts b/examples/reproducers/popover-flip-bug/repro.spec.ts new file mode 100644 index 00000000..ec64a5fb --- /dev/null +++ b/examples/reproducers/popover-flip-bug/repro.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test"; +import type { Locator } from "@playwright/test"; +import { + applyReducedMotion, + failOnConsoleErrors, +} from "../../../tests/e2e/helpers/uiTestUtils"; + +async function getPanelMetrics(locator: Locator) { + return locator.evaluate((node) => { + const rect = node.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const tokenValue = window + .getComputedStyle(document.documentElement) + .getPropertyValue("--popover-inline-safe-max") + .trim(); + const popoverMaxWidth = window + .getComputedStyle(node) + .getPropertyValue("--popover-max-width") + .trim(); + + return { + rect: { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }, + viewport: { width: viewportWidth, height: viewportHeight }, + placement: node.dataset.popoverPlacement || "", + state: node.dataset.popoverState || "", + popoverMaxWidth, + tokenMaxWidth: tokenValue, + }; + }); +} + +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("keeps the bottom-right grid menu inside the viewport", async ({ page }) => { + await applyReducedMotion(page); + failOnConsoleErrors(page); + // Ensure we are testing against the correct page served by the root http-server + await page.goto("/docs/popover-scenarios.html", { waitUntil: "networkidle" }); + + // 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); +}); diff --git a/examples/reproducers/video-card-bug/README.md b/examples/reproducers/video-card-bug/README.md new file mode 100644 index 00000000..efb15977 --- /dev/null +++ b/examples/reproducers/video-card-bug/README.md @@ -0,0 +1,21 @@ +# VideoCard Block Action Bug Reproducer + +This reproducer demonstrates a bug where the VideoCard fails to restore the trusted mute hide state after a block action is performed on an overridden video. + +## Running the Reproducer + +1. Ensure dependencies are installed: + ```bash + npm ci + ``` + +2. Run the reproducer script: + ```bash + node examples/reproducers/video-card-bug/repro.mjs + ``` + +## Expected Behavior + +The test should FAIL with an assertion error similar to: +`AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: false !== true` +indicating that `contextAfterHide.activeHidden` is false instead of true. diff --git a/examples/reproducers/video-card-bug/repro.mjs b/examples/reproducers/video-card-bug/repro.mjs new file mode 100644 index 00000000..b8e6f602 --- /dev/null +++ b/examples/reproducers/video-card-bug/repro.mjs @@ -0,0 +1,196 @@ +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 } from "../../../js/state/cache.js"; +import { + withMockedNostrTools, + createModerationAppHarness, +} from "../../../tests/helpers/moderation-test-helpers.mjs"; +import { + 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); + assert.equal(contextAfterHide.overrideActive, false); + assert.equal(contextAfterHide.activeHidden, true); + assert.equal(card.getRoot().dataset.moderationHidden, "true"); + assert.equal(card.moderationBlockButton, null); +}); diff --git a/scripts/build-dist.mjs b/scripts/build-dist.mjs index 02a633f0..899dc239 100644 --- a/scripts/build-dist.mjs +++ b/scripts/build-dist.mjs @@ -60,6 +60,10 @@ function copyDir(src, dest) { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { + // Exclude reproducers from distribution + if (src.endsWith('examples') && entry.name === 'reproducers') { + continue; + } const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs index 69cb9933..508728bb 100644 --- a/tests/app-batch-fetch-profiles.test.mjs +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -55,6 +55,9 @@ test("batchFetchProfiles handles fast and failing relays", async () => { const originalWriteRelays = Array.isArray(nostrClient.writeRelays) ? [...nostrClient.writeRelays] : nostrClient.writeRelays; + const originalReadRelays = Array.isArray(nostrClient.readRelays) + ? [...nostrClient.readRelays] + : nostrClient.readRelays; const originalPool = nostrClient.pool; const poolListCalls = []; @@ -62,6 +65,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => { nostrClient.relays = [FAST_RELAY, FAIL_RELAY]; nostrClient.readRelays = [FAST_RELAY, FAIL_RELAY]; nostrClient.writeRelays = [FAST_RELAY, FAIL_RELAY]; + nostrClient.readRelays = [FAST_RELAY, FAIL_RELAY]; nostrClient.pool = { list: (relays, filters) => { // console.log("pool.list called with relays:", relays); @@ -99,6 +103,7 @@ test("batchFetchProfiles handles fast and failing relays", async () => { nostrClient.relays = originalRelays; nostrClient.readRelays = originalReadRelays; nostrClient.writeRelays = originalWriteRelays; + nostrClient.readRelays = originalReadRelays; nostrClient.pool = originalPool; }