mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
add minimal reproducers for hashtag preferences and moderation flakiness issues
- Adds `examples/reproducers/issue-hashtag-prefs/reproduce_issue.mjs` to reproduce flaky hashtag preferences unit test. - Adds `examples/reproducers/issue-moderation-flake/reproduce_flake.spec.ts` to reproduce flaky moderation visual test in headless mode. - Includes README instructions for running each reproducer. Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
7164475c5e
commit
738c839302
5 files changed
+332
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Hashtag Preferences Flakiness Reproducer
|
||||
|
||||
This script reproduces a flaky unit test in `tests/hashtag-preferences.test.mjs`.
|
||||
|
||||
## Issue
|
||||
The original test case "load defers permission-required decrypts until explicitly enabled" was flaky due to `runNip07WithRetry/microtask` timing. The test has been extracted into a standalone script `reproduce_issue.mjs` here.
|
||||
|
||||
## How to Run
|
||||
|
||||
From the root of the repository:
|
||||
```bash
|
||||
node examples/reproducers/issue-hashtag-prefs/reproduce_issue.mjs
|
||||
```
|
||||
|
||||
## Expected Outcome
|
||||
- The test may pass (flake is resolved or timing is favorable).
|
||||
- Or it may fail with an error related to "Decrypt permissions are required", but the assertion `decryptCalls.length` being 0 instead of 1 (if the promise resolution happens too late).
|
||||
@@ -0,0 +1,175 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Adjust imports for location in examples/reproducers/issue-hashtag-prefs/
|
||||
const hashtagPreferencesModule = await import(
|
||||
"../../../js/services/hashtagPreferencesService.js"
|
||||
);
|
||||
const hashtagPreferences = hashtagPreferencesModule.default;
|
||||
const [{ nostrClient }, { setActiveSigner, clearActiveSigner }] =
|
||||
await Promise.all([
|
||||
import("../../../js/nostrClientFacade.js"),
|
||||
import("../../../js/nostr/client.js"),
|
||||
]);
|
||||
const { relayManager } = await import("../../../js/relayManager.js");
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
const originalRelayEntries = relayManager.getEntries();
|
||||
const originalWindowNostr = window.nostr;
|
||||
|
||||
function restoreNostrClient() {
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = Array.isArray(originalRelays)
|
||||
? [...originalRelays]
|
||||
: originalRelays;
|
||||
nostrClient.writeRelays = Array.isArray(originalWriteRelays)
|
||||
? [...originalWriteRelays]
|
||||
: originalWriteRelays;
|
||||
}
|
||||
|
||||
function restoreRelayManager() {
|
||||
relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
hashtagPreferences.reset();
|
||||
restoreNostrClient();
|
||||
restoreRelayManager();
|
||||
window.nostr = originalWindowNostr;
|
||||
|
||||
setActiveSigner({
|
||||
signEvent: async (e) => ({ ...e, id: "fake" }),
|
||||
nip44Encrypt: async () => "encrypted",
|
||||
nip04Encrypt: async () => "encrypted",
|
||||
});
|
||||
|
||||
nostrClient.pool = {
|
||||
list: async () => [],
|
||||
publish: (urls, event) => ({
|
||||
on: (eventName, handler) => {
|
||||
if (eventName === "ok") {
|
||||
setTimeout(() => handler(), 0);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
nostrClient.writeRelays = ["wss://mock.relay"];
|
||||
await hashtagPreferences.load("0".repeat(64));
|
||||
|
||||
if (nostrClient.extensionPermissionCache) {
|
||||
nostrClient.extensionPermissionCache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
restoreNostrClient();
|
||||
restoreRelayManager();
|
||||
window.nostr = originalWindowNostr;
|
||||
clearActiveSigner();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
|
||||
test(
|
||||
"REPRO: load defers permission-required decrypts until explicitly enabled",
|
||||
{ concurrency: false },
|
||||
async () => {
|
||||
const pubkey = "e".repeat(64);
|
||||
|
||||
const relayUrls = ["wss://relay-permissions.example"];
|
||||
const originalRelayEntries = relayManager.getEntries();
|
||||
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
|
||||
|
||||
const originalFetchIncremental = nostrClient.fetchListIncrementally;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
const originalWindowNostr = window.nostr;
|
||||
|
||||
let fetchCalls = 0;
|
||||
|
||||
const event = {
|
||||
id: "pref-permission",
|
||||
created_at: 400,
|
||||
pubkey,
|
||||
content: "cipher-permission",
|
||||
tags: [["encrypted", "nip04"]],
|
||||
};
|
||||
|
||||
fetchCalls = 0;
|
||||
nostrClient.fetchListIncrementally = async () => {
|
||||
fetchCalls += 1;
|
||||
return fetchCalls <= 4 ? [event] : [];
|
||||
};
|
||||
nostrClient.relays = relayUrls;
|
||||
nostrClient.writeRelays = relayUrls;
|
||||
|
||||
const decryptCalls = [];
|
||||
const permissionState = { enabled: false };
|
||||
|
||||
window.nostr = {
|
||||
enable: async () => {},
|
||||
getPublicKey: async () => pubkey,
|
||||
nip04: {
|
||||
decrypt: async () => {
|
||||
if (!permissionState.enabled) throw new Error("permission denied");
|
||||
decryptCalls.push("nip04");
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
interests: ["Late"],
|
||||
disinterests: [],
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
clearActiveSigner();
|
||||
|
||||
try {
|
||||
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false });
|
||||
|
||||
assert.equal(
|
||||
decryptCalls.length,
|
||||
0,
|
||||
"decryption should be skipped when permissions are deferred",
|
||||
);
|
||||
assert.equal(
|
||||
hashtagPreferences.lastLoadError?.code,
|
||||
"hashtag-preferences-permission-required",
|
||||
"permission-required errors should be captured for deferred prompts",
|
||||
);
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), []);
|
||||
|
||||
// Uncommented flaky part
|
||||
permissionState.enabled = true;
|
||||
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: true });
|
||||
|
||||
assert.equal(
|
||||
decryptCalls.length,
|
||||
1,
|
||||
"explicit permission prompts should retry decryption",
|
||||
);
|
||||
assert.deepEqual(hashtagPreferences.getInterests(), ["late"]);
|
||||
} finally {
|
||||
relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false });
|
||||
nostrClient.fetchListIncrementally = originalFetchIncremental;
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
window.nostr = originalWindowNostr;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
# Moderation Flakiness Reproducer
|
||||
|
||||
This script reproduces a visual regression test flakiness in `tests/visual/moderation.spec.ts`.
|
||||
|
||||
## Issue
|
||||
The test case "trusted mute hide fixture annotates mute reason and can be overridden" is flaky in headless environments due to layout issues (potentially 0x0 size) causing visibility checks to fail. The assertion `await expect(showAnywayButton).toBeVisible()` was commented out in the original test.
|
||||
|
||||
## How to Run
|
||||
|
||||
From the root of the repository:
|
||||
```bash
|
||||
npm run build
|
||||
npx playwright test -c examples/reproducers/issue-moderation-flake/playwright.config.ts
|
||||
```
|
||||
|
||||
## Expected Outcome
|
||||
- The test `examples/reproducers/issue-moderation-flake/reproduce_flake.spec.ts` should fail in headless mode if the layout issue occurs.
|
||||
- If the CSS loads correctly and the environment supports the layout, it may pass (flake is resolved).
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const HOST = process.env.PLAYWRIGHT_HOST ?? "127.0.0.1";
|
||||
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: ".",
|
||||
outputDir: "../../../artifacts/test-results-repro",
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
webServer: {
|
||||
command: `npx http-server ../../../ -p ${PORT} -a ${HOST} -c-1 --silent`,
|
||||
url: `${BASE_URL}/docs/kitchen-sink.html`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
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"]');
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||
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("REPRO: trusted mute hide fixture annotates mute reason and can be overridden (should fail)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
|
||||
await waitForFixtureReady(page);
|
||||
|
||||
const muteCard = page.locator('[data-test-id="trusted-mute-hide"]');
|
||||
const badge = muteCard.locator('[data-moderation-badge="true"]');
|
||||
const thumbnail = muteCard.locator('img[data-video-thumbnail]');
|
||||
|
||||
await expect(muteCard).toHaveAttribute(
|
||||
"data-moderation-override-available",
|
||||
"true"
|
||||
);
|
||||
const showAnywayButton = muteCard.getByRole("button", { name: "Show anyway" });
|
||||
const restoreButtonQuery = muteCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hide-reason", "trusted-mute-hide");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hide-trusted-mute-count", "1");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1");
|
||||
await expect(badge).toContainText("Hidden · 1 trusted mute");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
|
||||
// This line is commented out in the original test because it fails in headless mode.
|
||||
// We uncomment it here to reproduce the failure.
|
||||
await expect(showAnywayButton).toBeVisible();
|
||||
|
||||
const showAnywayLocator = muteCard.locator('button[data-moderation-action="override"]');
|
||||
await expect(showAnywayLocator).toHaveCount(1);
|
||||
await expect(showAnywayLocator).toHaveAttribute("aria-label", "Show anyway");
|
||||
await expect(restoreButtonQuery).toHaveCount(0);
|
||||
|
||||
await showAnywayButton.click();
|
||||
|
||||
await expect(muteCard).not.toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(muteCard.locator('[data-moderation-badge="true"]')).toContainText(
|
||||
"Showing despite 1 trusted mute",
|
||||
);
|
||||
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
|
||||
const restoreButton = muteCard.getByRole("button", {
|
||||
name: RESTORE_BUTTON_LABEL,
|
||||
});
|
||||
await expect(restoreButton).toBeVisible();
|
||||
|
||||
await restoreButton.click();
|
||||
|
||||
await expect(muteCard).toHaveAttribute("data-moderation-hidden", "true");
|
||||
await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway");
|
||||
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
|
||||
await expect(muteCard.getByRole("button", { name: "Show anyway" })).toBeVisible();
|
||||
});
|
||||
Reference in new issue
Block a user