Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369import { afterEach, mock } from "node:test";import { DataLayer } from "/js/dataLayer/dataLayer.js";import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js";import { DraftMediaStore } from "/js/drafts.js";import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";import { Constellation } from "/js/constellation.js";import { Signal, SignalMap } from "/js/signals.js";
const disposables = new Set();afterEach(() => { for (const disposable of disposables) disposable.dispose(); disposables.clear();});
export function trackDisposable(disposable) { disposables.add(disposable); return disposable;}
export function makeTestDataLayer({ api: apiOverrides = {}, identityResolver, draftMediaStore, hiddenFeedItemsStore, constellation,} = {}) { const api = { getProfile: async () => null, isAuthenticated: false, getPreferences: async () => [], getLabelers: async () => [], updatePreferences: async () => {}, ...apiOverrides, }; const dataLayer = new DataLayer( api, new PreferencesProvider(api), identityResolver ?? { resolveHandle: async () => null }, draftMediaStore ?? new DraftMediaStore("test-media"), hiddenFeedItemsStore ?? new HiddenFeedItemsStore(), constellation ?? new Constellation(), ); trackDisposable(dataLayer.derived.liveStatusScheduler); return dataLayer;}
export function makeTestPluginService(overrides = {}) { return { $slots: new SignalMap(), $richTextTransformsVersion: new Signal.State(0), getSlotRegistrations: () => [], getRenderer: () => ({ createRoot: () => ({ render: () => null, reset: () => {} }), }), transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, getClaimedFacetTypes: () => new Set(), getSidebarItems: () => [], getPreviewPlugins: () => [], getPostContextMenuItems: async () => [], getProfileContextMenuItems: async () => [], ...overrides, };}
// Stubs the four declarative.ensure* record-resolution methods with plausible// default fixtures. Pass overrides for methods a test needs to control.export function stubRecordLinkResolution(dataLayer, overrides = {}) { const defaults = { ensureFeedGenerator: async (uri) => ({ uri, cid: "feedcid", displayName: "Cool Feed", creator: { did: "did:plc:creator1", handle: "creator1.test" }, }), ensureList: async (uri) => ({ uri, cid: "listcid", name: "Cool List", creator: { did: "did:plc:creator1", handle: "creator1.test" }, }), ensureStarterPack: async (uri) => ({ $type: "app.bsky.graph.defs#starterPackView", uri, cid: "packcid", record: { name: "Cool Pack", description: "People to follow" }, creator: { did: "did:plc:creator1", handle: "creator1.test" }, }), ensurePost: async (uri) => ({ uri, cid: "postcid", author: { did: "did:plc:creator1", handle: "creator1.test", displayName: "Creator One", avatar: null, }, record: { text: "Original post", createdAt: "2025-01-01T00:00:00Z" }, indexedAt: "2025-01-01T00:00:00.000Z", labels: [], }), }; for (const [name, impl] of Object.entries({ ...defaults, ...overrides })) { mock.method(dataLayer.declarative, name, impl); }}
// Replace a status-tracked loader on `requests` with a mock impl, keeping the// real enableStatus wrapper so the statusStore populates like in production.// Pre-handles rejections since some loaders are fired without awaiting;// awaiters still observe the rejection.export function stubStatusTracked(requests, methodName, requestIdOrFn, impl) { const spy = mock.fn(impl); Object.defineProperty(spy, "name", { value: methodName }); requests.enableStatus(spy, requestIdOrFn); const wrapped = requests[methodName]; requests[methodName] = (...args) => { const p = wrapped(...args); p.catch(() => {}); return p; }; return spy;}
const originalWindow = globalThis.window;
// Replaces globalThis.window with a proxy that intercepts location.href writes// so we can capture redirects without triggering JSDOM navigation errors.// Returns the captured hrefs array. Call restoreWindow() to undo.export function mockWindowLocation(search = "") { const capturedHrefs = []; const locationMock = { get search() { return search; }, get pathname() { return "/"; }, get hash() { return ""; }, get href() { return capturedHrefs.at(-1) ?? "http://localhost/"; }, set href(value) { capturedHrefs.push(value); }, assign(value) { capturedHrefs.push(value); }, reload() { capturedHrefs.push("reload"); }, }; globalThis.window = new Proxy(originalWindow, { get(target, prop) { if (prop === "location") return locationMock; const val = target[prop]; return typeof val === "function" ? val.bind(target) : val; }, }); return capturedHrefs;}
export function restoreWindow() { globalThis.window = originalWindow;}
// Lets pending promise chains settle without advancing fake timers.export async function flushMicrotasks(turns = 5) { for (let turn = 0; turn < turns; turn++) { await Promise.resolve(); }}
export function setDocumentVisibility(state) { Object.defineProperty(document, "visibilityState", { value: state, configurable: true, }); document.dispatchEvent(new window.Event("visibilitychange"));}
export function restoreDocumentVisibility() { delete document.visibilityState;}
export async function waitFor(predicate, { timeout = 2000 } = {}) { const deadline = Number(process.hrtime.bigint() / 1_000_000n) + timeout; while (!predicate()) { if (Number(process.hrtime.bigint() / 1_000_000n) > deadline) { throw new Error("waitFor timed out"); } await new Promise((resolve) => setTimeout(resolve, 5)); }}
export async function respondToConfirm(confirmed) { const testId = confirmed ? "modal-confirm-button" : "modal-cancel-button"; await waitFor(() => document.querySelector(`[data-testid="${testId}"]`)); document.querySelector(`[data-testid="${testId}"]`).click();}
export async function chooseModal(value) { const testId = `modal-choice-${value}`; await waitFor(() => document.querySelector(`[data-testid="${testId}"]`)); document.querySelector(`[data-testid="${testId}"]`).click();}
// A callable fetch replacement. Assign to globalThis.fetch, register routes// with __intercept(matcher, handler), and inspect captured requests on `calls`.// Matchers are strings (matched by URL prefix) or regex (matched with .test).export class MockFetch { constructor() { const routes = []; const calls = []; const fetch = async (url, options) => { calls.push({ url, options }); for (const route of routes) { const matches = typeof route.matcher === "string" ? url.startsWith(route.matcher) : route.matcher.test(url); if (matches) { return route.handler(url, options); } } throw new Error(`Unhandled fetch: ${url}`); }; fetch.calls = calls; fetch.__intercept = (matcher, handler) => { routes.push({ matcher, handler }); return fetch; }; fetch.__interceptJson = (matcher, body) => { return fetch.__intercept(matcher, async () => ({ ok: true, status: 200, statusText: "OK", headers: { get: () => null }, json: async () => body, text: async () => "", })); }; return fetch; }}
// JSDOM has no IndexedDB; this minimal fake covers the subset KVIndexedDB uses// (open/transaction/objectStore/put/get/getKey/delete).class FakeIDBRequest { constructor() { this.onsuccess = null; this.onerror = null; this.onupgradeneeded = null; } _resolve(result) { this.result = result; queueMicrotask(() => this.onsuccess?.()); } _reject(error) { this.error = error; queueMicrotask(() => this.onerror?.()); }}
class FakeObjectStore { constructor(records, { failWrites = false } = {}) { this.records = records; this.failWrites = failWrites; } put(value, key) { const request = new FakeIDBRequest(); if (this.failWrites) { request._reject(new Error("QuotaExceededError")); } else { this.records.set(key, value); request._resolve(undefined); } return request; } get(key) { const request = new FakeIDBRequest(); request._resolve(this.records.get(key)); return request; } getKey(key) { const request = new FakeIDBRequest(); request._resolve(this.records.has(key) ? key : undefined); return request; } delete(key) { const request = new FakeIDBRequest(); this.records.delete(key); request._resolve(undefined); return request; }}
export function installFakeIndexedDB({ failWrites = false } = {}) { const records = new Map(); const openCalls = []; const createdStores = []; globalThis.indexedDB = { open(dbName, version) { openCalls.push({ dbName, version }); const request = new FakeIDBRequest(); const db = { createObjectStore(storeName) { createdStores.push(storeName); }, transaction() { return { objectStore: () => new FakeObjectStore(records, { failWrites }), }; }, }; request.result = db; queueMicrotask(() => { request.onupgradeneeded?.(); request.onsuccess?.(); }); return request; }, }; return { records, openCalls, createdStores };}
// JSDOM has no BroadcastChannel, and node's rejects its own MessageEvent once// JSDOM has replaced the global Event. This double keeps the parts callers rely// on: async delivery to every same-named channel except the sender.export function installFakeBroadcastChannel() { const channelsByName = new Map();
// window.EventTarget, not node's — node's rejects JSDOM MessageEvents. class FakeBroadcastChannel extends window.EventTarget { constructor(name) { super(); this.name = name; this.closed = false; const peers = channelsByName.get(name) ?? new Set(); peers.add(this); channelsByName.set(name, peers); }
postMessage(data) { if (this.closed) return; for (const peer of channelsByName.get(this.name) ?? []) { if (peer === this || peer.closed) continue; setTimeout(() => { if (peer.closed) return; peer.dispatchEvent(new window.MessageEvent("message", { data })); }, 0); } }
close() { this.closed = true; channelsByName.get(this.name)?.delete(this); } }
const original = globalThis.BroadcastChannel; globalThis.BroadcastChannel = FakeBroadcastChannel; return () => { globalThis.BroadcastChannel = original; };}