From 1b7575eaab2beb3cfa79dccfa5e5ff6c0a7ce0cb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:18:28 +0000 Subject: [PATCH] Add minimal reproducers for known bugs - Added reproducer for mobile sidebar margin mismatch (tests/visual/overlay-layers.spec.ts). - Added reproducer for Moderation UI "Show anyway" button visibility (tests/visual/moderation.spec.ts). - Added reproducer for NIP-07 login unit test ReferenceError (tests/nostr-login-permissions.test.mjs). - Added examples/reproducers/playwright.config.ts to support running these tests. - Updated examples/reproducers/README.md with instructions. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- examples/reproducers/README.md | 31 +++ .../issue-moderation-ui/repro.spec.ts | 99 +++++++- .../reproducers/issue-nip07-login/repro.mjs | 193 ++++++++++++++ .../issue-sidebar-layout/repro.spec.ts | 240 ++++++++++++++++++ examples/reproducers/playwright.config.ts | 14 +- 5 files changed, 561 insertions(+), 16 deletions(-) create mode 100644 examples/reproducers/issue-nip07-login/repro.mjs create mode 100644 examples/reproducers/issue-sidebar-layout/repro.spec.ts diff --git a/examples/reproducers/README.md b/examples/reproducers/README.md index 2b0b257a..80655e12 100644 --- a/examples/reproducers/README.md +++ b/examples/reproducers/README.md @@ -21,3 +21,34 @@ Example structure: - `buildVideoPostEvent` crashes on circular objects if not handled (Fixed). - `normalizeAndAugmentMagnet` crashes on non-iterable `extraTrackers` (Fixed). - `build...` functions crash on `null`/`undefined` input (Fixed). + +# Bug Reproducers + +This directory also contains minimal reproducers for known bugs in the repository. + +## Sidebar Layout (Mobile Margin Mismatch) + +**Issue**: The sidebar layout on mobile viewports has a margin calculation mismatch. +**Reproducer**: `examples/reproducers/issue-sidebar-layout/repro.spec.ts` +**Run**: +```bash +npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/issue-sidebar-layout/repro.spec.ts +``` + +## Moderation UI ("Show Anyway" Button Visibility) + +**Issue**: The "Show anyway" button fails to appear in the DOM during tests, causing visibility checks to fail. +**Reproducer**: `examples/reproducers/issue-moderation-ui/repro.spec.ts` +**Run**: +```bash +npx playwright test -c examples/reproducers/playwright.config.ts examples/reproducers/issue-moderation-ui/repro.spec.ts +``` + +## NIP-07 Login Test (ReferenceError) + +**Issue**: The unit test for NIP-07 login fallback fails with `ReferenceError: plainCall is not defined`. +**Reproducer**: `examples/reproducers/issue-nip07-login/repro.mjs` +**Run**: +```bash +node examples/reproducers/issue-nip07-login/repro.mjs +``` diff --git a/examples/reproducers/issue-moderation-ui/repro.spec.ts b/examples/reproducers/issue-moderation-ui/repro.spec.ts index e6b5a912..794d9e79 100644 --- a/examples/reproducers/issue-moderation-ui/repro.spec.ts +++ b/examples/reproducers/issue-moderation-ui/repro.spec.ts @@ -1,43 +1,118 @@ import { expect, test } from "@playwright/test"; const FIXTURE_URL = "/docs/moderation/fixtures/index.html"; +const STORAGE_KEY = "bitvid:moderation:fixture-overrides"; +const SENTINEL_KEY = "__moderationFixturesInit__"; const RESTORE_BUTTON_LABEL = "Restore default moderation"; async function waitForFixtureReady(page) { await page.waitForSelector('body[data-ready="true"]'); } -test("repro: show anyway override button visibility", async ({ page }) => { - // 1. Navigate to fixture +function setupStorageReset(page) { + return page.addInitScript(({ storageKey, sentinelKey }) => { + try { + const session = window.sessionStorage; + const local = window.localStorage; + if (session && !session.getItem(sentinelKey)) { + local?.removeItem(storageKey); + session.setItem(sentinelKey, "1"); + } + } catch { + /* ignore reset failures in tests */ + } + }, { storageKey: STORAGE_KEY, sentinelKey: SENTINEL_KEY }); +} + +test.beforeEach(async ({ page }) => { + await setupStorageReset(page); +}); + +test.afterEach(async ({ page }) => { + try { + await page.evaluate(({ key, sentinel }) => { + window.localStorage?.removeItem(key); + window.sessionStorage?.removeItem(sentinel); + }, { key: STORAGE_KEY, sentinel: SENTINEL_KEY }); + } catch { + // ignore cleanup errors when page already closed + } +}); + +test.describe("moderation fixtures", () => { + test("show anyway override persists across reloads", async ({ page }) => { await page.goto(FIXTURE_URL, { waitUntil: "networkidle" }); await waitForFixtureReady(page); - // 2. Locate the card that should have the button const overrideCard = page.locator('[data-test-id="show-anyway"]'); + const thumbnail = overrideCard.locator('img[data-video-thumbnail]'); - // 3. Verify it has the override available attribute await expect(overrideCard).toHaveAttribute( "data-moderation-override-available", "true" ); - - // 4. Locate the button const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" }); const restoreButtonQuery = overrideCard.getByRole("button", { name: RESTORE_BUTTON_LABEL, }); - // 5. Assert visibility (this is expected to fail) - console.log("Checking visibility of 'Show anyway' button..."); - await expect(showAnywayButton).toBeVisible(); + await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked"); + await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred"); - console.log("'Show anyway' button is visible. Attempting click..."); - // 6. Click (this is expected to fail/timeout if not visible) + // Uncommented failures: + await expect(showAnywayButton).toBeVisible(); + await expect(restoreButtonQuery).toHaveCount(0); + + // Uncommented failure: await showAnywayButton.click({ force: true }); - // 7. Verify state change + /* await expect(overrideCard).toHaveAttribute( "data-moderation-override", "show-anyway" ); + await expect(overrideCard).not.toHaveAttribute("data-autoplay-policy", "blocked"); + await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred"); + const restoreButton = overrideCard.getByRole("button", { + name: RESTORE_BUTTON_LABEL, + }); + await expect(restoreButton).toBeVisible(); + await expect(overrideCard.getByRole("button", { name: "Show anyway" })).toHaveCount(0); + + await page.reload({ waitUntil: "networkidle" }); + await waitForFixtureReady(page); + + const reloadedCard = page.locator('[data-test-id="show-anyway"]'); + const reloadedThumbnail = reloadedCard.locator('img[data-video-thumbnail]'); + const reloadedRestoreButton = reloadedCard.getByRole("button", { + name: RESTORE_BUTTON_LABEL, + }); + + await expect(reloadedCard).toHaveAttribute( + "data-moderation-override", + "show-anyway" + ); + await expect(reloadedCard).not.toHaveAttribute("data-autoplay-policy", "blocked"); + await expect(reloadedThumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred"); + await expect(reloadedRestoreButton).toBeVisible(); + await expect( + reloadedCard.getByRole("button", { name: "Show anyway" }) + ).toHaveCount(0); + + await reloadedRestoreButton.click(); + + await expect(reloadedCard).not.toHaveAttribute( + "data-moderation-override", + "show-anyway" + ); + await expect(reloadedCard).toHaveAttribute("data-autoplay-policy", "blocked"); + await expect(reloadedThumbnail).toHaveAttribute("data-thumbnail-state", "blurred"); + await expect( + reloadedCard.getByRole("button", { name: RESTORE_BUTTON_LABEL }) + ).toHaveCount(0); + await expect( + reloadedCard.getByRole("button", { name: "Show anyway" }) + ).toBeVisible(); + */ + }); }); diff --git a/examples/reproducers/issue-nip07-login/repro.mjs b/examples/reproducers/issue-nip07-login/repro.mjs new file mode 100644 index 00000000..6d5081a1 --- /dev/null +++ b/examples/reproducers/issue-nip07-login/repro.mjs @@ -0,0 +1,193 @@ +import "../../../tests/test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nostrClient } from "../../../js/nostrClientFacade.js"; +import { NostrClient, __testExports } from "../../../js/nostr/client.js"; +import nip07Provider from "../../../js/services/authProviders/nip07.js"; +import { accessControl } from "../../../js/accessControl.js"; + +const { + DEFAULT_NIP07_ENCRYPTION_METHODS, +} = __testExports ?? {}; + +const HEX_PUBKEY = "f".repeat(64); +const PERMISSIONS_STORAGE_KEY = "bitvid:nip07:permissions"; + +function clearStoredPermissions() { + if (typeof localStorage === "undefined" || !localStorage) { + return; + } + try { + localStorage.removeItem(PERMISSIONS_STORAGE_KEY); + } catch (error) { + // Ignore storage cleanup issues in tests + } +} + +test.beforeEach(() => { + clearStoredPermissions(); +}); + +test.afterEach(() => { + clearStoredPermissions(); +}); + +function setupLoginEnvironment({ enableImpl, getPublicKey = HEX_PUBKEY } = {}) { + const previousWindow = typeof global.window === "undefined" ? undefined : global.window; + const previousGlobalNostr = global.nostr; + const previousGlobalNostrTools = global.NostrTools; + const previousCanonicalTools = global.__BITVID_CANONICAL_NOSTR_TOOLS__; + const previousNostrToolsReady = global.nostrToolsReady; + + const windowRef = previousWindow ?? global; + global.window = windowRef; + + const previousWindowNostr = previousWindow?.nostr; + const previousWindowNostrTools = previousWindow?.NostrTools; + + const enableCalls = []; + const nostrStub = { + enable: (options) => { + enableCalls.push(options); + if (typeof enableImpl === "function") { + return Promise.resolve(enableImpl(options, enableCalls.length)); + } + return Promise.resolve(); + }, + getPublicKey: () => Promise.resolve(getPublicKey), + }; + + windowRef.nostr = nostrStub; + global.nostr = nostrStub; + + const toolkitStub = { + nip19: { + npubEncode: (hex) => `npub${hex}`, + }, + }; + + windowRef.NostrTools = toolkitStub; + global.NostrTools = toolkitStub; + global.__BITVID_CANONICAL_NOSTR_TOOLS__ = toolkitStub; + global.nostrToolsReady = Promise.resolve({ ok: true, value: toolkitStub }); + + const originalAccessControl = { + canAccess: accessControl.canAccess, + getWhitelist: accessControl.getWhitelist, + getBlacklist: accessControl.getBlacklist, + isBlacklisted: accessControl.isBlacklisted, + }; + + accessControl.canAccess = () => true; + accessControl.getWhitelist = () => []; + accessControl.getBlacklist = () => []; + accessControl.isBlacklisted = () => false; + + if ( + nostrClient.extensionPermissionCache && + typeof nostrClient.extensionPermissionCache.clear === "function" + ) { + nostrClient.extensionPermissionCache.clear(); + } + clearStoredPermissions(); + + return { + enableCalls, + restore() { + accessControl.canAccess = originalAccessControl.canAccess; + accessControl.getWhitelist = originalAccessControl.getWhitelist; + accessControl.getBlacklist = originalAccessControl.getBlacklist; + accessControl.isBlacklisted = originalAccessControl.isBlacklisted; + + if (previousWindow === undefined) { + delete global.window; + } else { + global.window = previousWindow; + if (previousWindow) { + previousWindow.nostr = previousWindowNostr; + previousWindow.NostrTools = previousWindowNostrTools; + } + } + + if (previousGlobalNostr === undefined) { + delete global.nostr; + } else { + global.nostr = previousGlobalNostr; + } + + if (previousGlobalNostrTools === undefined) { + delete global.NostrTools; + } else { + global.NostrTools = previousGlobalNostrTools; + } + + global.__BITVID_CANONICAL_NOSTR_TOOLS__ = previousCanonicalTools; + + if (previousNostrToolsReady === undefined) { + delete global.nostrToolsReady; + } else { + global.nostrToolsReady = previousNostrToolsReady; + } + + if ( + nostrClient.extensionPermissionCache && + typeof nostrClient.extensionPermissionCache.clear === "function" + ) { + nostrClient.extensionPermissionCache.clear(); + } + clearStoredPermissions(); + }, + }; +} + +test("NIP-07 login falls back when structured permissions fail", async () => { + clearStoredPermissions(); + const env = setupLoginEnvironment({ + enableImpl(options) { + if ( + options && + Array.isArray(options.permissions) && + options.permissions.length && + typeof options.permissions[0] === "object" + ) { + return Promise.reject(new Error("object permissions unsupported")); + } + if (options && Array.isArray(options.permissions)) { + return Promise.resolve(); + } + return Promise.reject(new Error("unexpected enable invocation")); + }, + }); + + try { + const result = await nip07Provider.login({ nostrClient }); + const pubkey = result.pubkey; + assert.equal(pubkey, HEX_PUBKEY); + assert.ok(env.enableCalls.length >= 2, "should retry with alternate payloads"); + const [objectCall, stringCall] = env.enableCalls; + assert.ok(Array.isArray(objectCall?.permissions)); + assert.equal( + typeof objectCall.permissions[0], + "object", + "structured permission request should be attempted first", + ); + assert.ok(Array.isArray(stringCall?.permissions)); + assert.equal( + typeof stringCall.permissions[0], + "string", + "string-based permissions should be attempted after structured payloads", + ); + + // THIS ASSERTION CAUSES ReferenceError: plainCall is not defined + assert.equal( + plainCall, + undefined, + "plain enable() should only be attempted if prior payloads fail", + ); + } finally { + env.restore(); + nostrClient.logout(); + clearStoredPermissions(); + } +}); diff --git a/examples/reproducers/issue-sidebar-layout/repro.spec.ts b/examples/reproducers/issue-sidebar-layout/repro.spec.ts new file mode 100644 index 00000000..b10e3982 --- /dev/null +++ b/examples/reproducers/issue-sidebar-layout/repro.spec.ts @@ -0,0 +1,240 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +declare global { + interface Window { + __testToast?: { + show(message: string, options?: Record): unknown; + }; + } +} + +test.describe("overlay layering tokens", () => { + async function dismissDisclaimerModal(page: Page) { + const modal = page.locator("#disclaimerModal"); + if ((await modal.count()) === 0) { + return; + } + + await page.evaluate(() => { + try { + window.localStorage?.setItem("hasSeenDisclaimer", "true"); + } catch (error) { + console.warn("Failed to persist disclaimer state", error); + } + document + .querySelectorAll("#disclaimerModal") + .forEach((node) => { + node.classList.add("hidden"); + node.setAttribute("data-open", "false"); + }); + document.documentElement?.classList.remove("modal-open"); + document.body?.classList.remove("modal-open"); + }); + + await page.waitForFunction(() => + Array.from( + document.querySelectorAll("#disclaimerModal") + ).every((modalElement) => modalElement.classList.contains("hidden")) + ); + } + + test("mobile sidebar shares desktop rail behavior", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/index.html", { waitUntil: "networkidle" }); + + await dismissDisclaimerModal(page); + + // Wait for initial fade-in to complete so opacity doesn't interfere with visibility checks + await page.waitForFunction( + () => !document.getElementById("sidebar")?.classList.contains("fade-in") + ); + + await expect(page.locator("#mobileMenuBtn")).toHaveCount(0); + + const collapseToggle = page.locator("#sidebarCollapseToggle"); + await expect(collapseToggle).toBeVisible(); + + const initialLayout = await page.evaluate(() => { + const sidebar = document.getElementById("sidebar"); + const app = document.getElementById("app"); + if (!sidebar || !app) { + throw new Error("Missing sidebar layout nodes"); + } + + const ensureProbeStyles = () => { + if (document.getElementById("sidebar-width-probe-style")) { + return; + } + const style = document.createElement("style"); + style.id = "sidebar-width-probe-style"; + style.textContent = ` + .sidebar-width-probe { + position: absolute; + visibility: hidden; + } + + .sidebar-width-probe--collapsed { + width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap)); + } + + .sidebar-width-probe--expanded { + width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap)); + } + `; + document.head.appendChild(style); + }; + + ensureProbeStyles(); + + const computeMargin = (widthVar) => { + const variant = + widthVar === "--sidebar-width-collapsed" + ? "sidebar-width-probe--collapsed" + : widthVar === "--sidebar-width-expanded" + ? "sidebar-width-probe--expanded" + : null; + if (!variant) { + throw new Error(`Unsupported sidebar width variable: ${widthVar}`); + } + + const probe = document.createElement("div"); + probe.classList.add("sidebar-width-probe", variant); + document.body.appendChild(probe); + const width = parseFloat(window.getComputedStyle(probe).width); + probe.remove(); + return width; + }; + + const marginLeft = parseFloat(window.getComputedStyle(app).marginLeft); + const state = sidebar.classList.contains("sidebar-expanded") + ? "expanded" + : sidebar.classList.contains("sidebar-collapsed") + ? "collapsed" + : "unknown"; + + return { + state, + marginLeft, + collapsedMargin: computeMargin("--sidebar-width-collapsed"), + expandedMargin: computeMargin("--sidebar-width-expanded"), + overlayExists: Boolean(document.getElementById("sidebarOverlay")), + }; + }); + + expect(initialLayout.overlayExists).toBe(false); + expect(initialLayout.state).toBe("collapsed"); + expect(Math.abs(initialLayout.marginLeft - initialLayout.collapsedMargin)).toBeLessThan(0.5); + + // Ensure the toggle is initialized + await expect(collapseToggle).toHaveAttribute("data-state", /.*/); + await collapseToggle.click({ force: true }); + + await page.waitForFunction(() => { + const sidebar = document.getElementById("sidebar"); + return ( + sidebar?.classList.contains("sidebar-expanded") && + !sidebar?.classList.contains("sidebar-collapsed") + ); + }); + + // Wait for sidebar transition to complete + await page.waitForTimeout(1000); + + // Ensure the footer button is scrolled into view (especially for mobile viewports) + await page.evaluate(() => { + const sidebar = document.getElementById("sidebar"); + if (sidebar) { + sidebar.scrollTop = sidebar.scrollHeight; + } + }); + + const expandedLayout = await page.evaluate(() => { + const sidebar = document.getElementById("sidebar"); + const app = document.getElementById("app"); + if (!sidebar || !app) { + throw new Error("Missing sidebar layout nodes"); + } + + const ensureProbeStyles = () => { + if (document.getElementById("sidebar-width-probe-style")) { + return; + } + const style = document.createElement("style"); + style.id = "sidebar-width-probe-style"; + style.textContent = ` + .sidebar-width-probe { + position: absolute; + visibility: hidden; + } + + .sidebar-width-probe--collapsed { + width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap)); + } + + .sidebar-width-probe--expanded { + width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap)); + } + `; + document.head.appendChild(style); + }; + + ensureProbeStyles(); + + const computeMargin = (widthVar) => { + const variant = + widthVar === "--sidebar-width-collapsed" + ? "sidebar-width-probe--collapsed" + : widthVar === "--sidebar-width-expanded" + ? "sidebar-width-probe--expanded" + : null; + if (!variant) { + throw new Error(`Unsupported sidebar width variable: ${widthVar}`); + } + + const probe = document.createElement("div"); + probe.classList.add("sidebar-width-probe", variant); + document.body.appendChild(probe); + const width = parseFloat(window.getComputedStyle(probe).width); + probe.remove(); + return width; + }; + + const marginLeft = parseFloat(window.getComputedStyle(app).marginLeft); + const state = sidebar.classList.contains("sidebar-expanded") + ? "expanded" + : sidebar.classList.contains("sidebar-collapsed") + ? "collapsed" + : "unknown"; + + return { + state, + marginLeft, + expectedMargin: computeMargin("--sidebar-width-expanded"), + }; + }); + + expect(expandedLayout.state).toBe("expanded"); + expect(Math.abs(expandedLayout.marginLeft - expandedLayout.expectedMargin)).toBeLessThan(0.5); + + const footerButton = page.locator("#footerDropdownButton"); + await expect(footerButton).toBeVisible(); + await footerButton.click(); + await expect(footerButton).toHaveAttribute("aria-expanded", "true"); + + const dropupState = await page.evaluate(() => { + const footerLinks = document.getElementById("footerLinksContainer"); + if (!footerLinks) { + throw new Error("Missing footer links container"); + } + return { + state: footerLinks.getAttribute("data-state"), + ariaHidden: footerLinks.getAttribute("aria-hidden"), + }; + }); + + expect(dropupState.state).toBe("expanded"); + expect(dropupState.ariaHidden).toBe("false"); + }); +}); +export {}; diff --git a/examples/reproducers/playwright.config.ts b/examples/reproducers/playwright.config.ts index 81c13072..5330254d 100644 --- a/examples/reproducers/playwright.config.ts +++ b/examples/reproducers/playwright.config.ts @@ -5,18 +5,24 @@ const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "4173", 10); const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${HOST}:${PORT}`; export default defineConfig({ - testDir: ".", + testDir: "../../", outputDir: "../../artifacts/test-results-repro", timeout: 60_000, - reporter: [["list"], ["html", { open: "never" }]], + reporter: [["list"]], use: { baseURL: BASE_URL, viewport: { width: 1280, height: 720 }, ignoreHTTPSErrors: true, }, + projects: [ + { + name: "repro", + testMatch: "examples/reproducers/**/*.spec.ts", + }, + ], webServer: { - // Config is in examples/reproducers, so cwd is there. We need to serve the repo root. - command: `npx http-server ../.. -p ${PORT} -a ${HOST} -c-1 --silent`, + command: `npx http-server . -p ${PORT} -a ${HOST} -c-1 --silent`, + cwd: "../../", url: `${BASE_URL}/docs/kitchen-sink.html`, reuseExistingServer: !process.env.CI, timeout: 120_000,