Merge pull request #1968 from PR0M3TH3AN/ai/reproducers-popover-video-20260129-656412173162674175

chore(ai): add minimal reproducers for known bugs
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 11:07:07 -05:00
commit 93f78e60ee
6 files changed
+330

No files matched your search

@@ -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.
@@ -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);
});
@@ -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.
@@ -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("<!DOCTYPE html><html><body></body></html>", {
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);
});
+4
View File
@@ -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()) {
+5
View File
@@ -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;
}