mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 10:22:25 +00:00
Merge pull request #2162 from PR0M3TH3AN/ai/reproducers-known-bugs-20240522-9928543418568370400
docs(repro): add minimal reproducers for known bugs
This commit is contained in:
7 files changed
+506
No files matched your search
@@ -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" ...
|
||||
```
|
||||
@@ -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(
|
||||
'<!DOCTYPE html><html><body><div id="modalMount"></div></body></html>',
|
||||
{
|
||||
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');
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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("<!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);
|
||||
// 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");
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user