mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* build(client): upgrade TypeScript to 6.0.3 Staging step toward TypeScript 7 (the native compiler), which needs its 7.1 stable API before typescript-eslint and Stryker's typescript-checker can run on it. TS 6 is the JS-based bridge release that aligns config defaults with 7. Two fallout fixes: - tsconfig.e2e.json: TS 6 defaults "types" to [] instead of every installed @types package, so the Playwright layer's Node globals (process, Buffer) need an explicit "types": ["node"]. - media-visibility.test.ts: TS 6's DOM lib adds scrollMargin to IntersectionObserver, so the mock grows the property. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(server): bump chi to 5.3.2, modernc.org/sqlite to 1.57.0, toolchain to go1.26.7 Go 1.27 deliberately deferred until 1.27.1 lands. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(tauri): bump tokio-tungstenite to 0.30, refresh Cargo.lock In-range lockfile refresh via cargo update; tungstenite 0.29/0.30 changes are client-API-neutral (header handling, server-side handshake hardening). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * build(client): upgrade vite 8, vitest 4, jsdom 30, stryker 10 + minors - vite 6 -> 8: Rolldown requires the function form of manualChunks; __dirname -> import.meta.dirname in configs - vitest 3 -> 4: browser provider moved to @vitest/browser-playwright; vi.fn() mocks now need explicit signatures (typed throughout tests); constructor mocks use function impls; restoreAllMocks no longer resets vi.fn state; matchMedia spies replaced with vi.stubGlobal - jsdom 29 -> 30: one internal bookkeeping abort listener per signal, listener-count regression tests adjusted (leak detection retained) - stryker 9 -> 10, @types/node 20 -> 24, eslint/oxlint/livekit-client minors - tsconfigs: explicit "types" now that TS6/vitest4 stop injecting @types/node ambiently; build config keeps Node globals out of src/ Validated: tsc (main/build/e2e), eslint, oxlint, knip, prettier, unit+integration (5196 tests), browser suite, vite build, stryker dry run. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn * ci: move Node 20 (EOL 2026-04-30) to Node 24 LTS The jsdom suite runs on modern Node without --no-experimental-webstorage: tests/setup.ts already replaces the shadowed localStorage with an in-memory shim. Client CLAUDE.md gotcha updated accordingly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw5KEz6gdD816Wxmm4A3Bn --------- Co-authored-by: Claude <noreply@anthropic.com>
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
observeMedia,
|
|
unobserveMedia,
|
|
pauseAllMedia,
|
|
resumeVisibleMedia,
|
|
destroyObserver,
|
|
} from "../../src/lib/media-visibility";
|
|
|
|
// Mock IntersectionObserver
|
|
let observerCallback: IntersectionObserverCallback;
|
|
const observeMock = vi.fn();
|
|
const unobserveMock = vi.fn();
|
|
const disconnectMock = vi.fn();
|
|
|
|
class MockIntersectionObserver implements IntersectionObserver {
|
|
readonly root: Element | null = null;
|
|
readonly rootMargin: string = "0px";
|
|
readonly scrollMargin: string = "0px";
|
|
readonly thresholds: readonly number[] = [0];
|
|
constructor(callback: IntersectionObserverCallback) {
|
|
observerCallback = callback;
|
|
}
|
|
observe = observeMock;
|
|
unobserve = unobserveMock;
|
|
disconnect = disconnectMock;
|
|
takeRecords(): IntersectionObserverEntry[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function createFakeImg(src: string): HTMLImageElement {
|
|
const img = document.createElement("img");
|
|
img.src = src;
|
|
Object.defineProperty(img, "naturalWidth", { value: 100 });
|
|
Object.defineProperty(img, "naturalHeight", { value: 100 });
|
|
return img;
|
|
}
|
|
|
|
function createWrapper(): HTMLDivElement {
|
|
return document.createElement("div");
|
|
}
|
|
|
|
function fireIntersection(entries: Array<{ target: Element; isIntersecting: boolean }>): void {
|
|
const fakeEntries = entries.map((e) => ({
|
|
target: e.target,
|
|
isIntersecting: e.isIntersecting,
|
|
boundingClientRect: {} as DOMRectReadOnly,
|
|
intersectionRatio: e.isIntersecting ? 1 : 0,
|
|
intersectionRect: {} as DOMRectReadOnly,
|
|
rootBounds: null,
|
|
time: Date.now(),
|
|
}));
|
|
observerCallback(fakeEntries, {} as IntersectionObserver);
|
|
}
|
|
|
|
function setupCanvasMocks(): () => void {
|
|
const mockCanvas = document.createElement("canvas");
|
|
const mockCtx = { drawImage: vi.fn() };
|
|
const origCreateElement = document.createElement.bind(document);
|
|
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
|
if (tag === "canvas") return mockCanvas;
|
|
return origCreateElement(tag);
|
|
});
|
|
vi.spyOn(mockCanvas, "getContext").mockReturnValue(mockCtx as any);
|
|
vi.spyOn(mockCanvas, "toDataURL").mockReturnValue("data:image/png;base64,frozen");
|
|
return () => vi.restoreAllMocks();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
|
vi.useFakeTimers();
|
|
observeMock.mockClear();
|
|
unobserveMock.mockClear();
|
|
disconnectMock.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
destroyObserver();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("media-visibility", () => {
|
|
it("observeMedia registers image with IntersectionObserver", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
expect(observeMock).toHaveBeenCalledWith(img);
|
|
});
|
|
|
|
it("adds play/pause button to wrapper", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
const btn = wrap.querySelector(".gif-play-btn");
|
|
expect(btn).not.toBeNull();
|
|
});
|
|
|
|
it("unobserveMedia stops observing and restores original src", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
img.src = "data:image/png;base64,frozen";
|
|
unobserveMedia(img);
|
|
expect(unobserveMock).toHaveBeenCalledWith(img);
|
|
expect(img.src).toBe("https://example.com/cat.gif");
|
|
});
|
|
|
|
it("does not double-observe same image", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
expect(observeMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("freezes GIF when it leaves viewport", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
fireIntersection([{ target: img, isIntersecting: false }]);
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
cleanup();
|
|
});
|
|
|
|
it("auto-pauses after 10 seconds", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
expect(img.src).toBe("https://example.com/cat.gif");
|
|
vi.advanceTimersByTime(10_000);
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
const btn = wrap.querySelector(".gif-play-btn");
|
|
expect(btn?.querySelector('svg[data-icon="play"]')).not.toBeNull();
|
|
cleanup();
|
|
});
|
|
|
|
it("play button click unfreezes and starts new 10s timer", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
vi.advanceTimersByTime(10_000);
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement;
|
|
btn.click();
|
|
expect(img.src).toBe("https://example.com/cat.gif");
|
|
vi.advanceTimersByTime(10_000);
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
cleanup();
|
|
});
|
|
|
|
it("freezes a GIF whose src the DOM normalizes away from the raw originalSrc (OC-0130)", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
// Mixed-case host: the <img> element's src getter returns this
|
|
// lowercased per the WHATWG URL spec, so it no longer string-matches
|
|
// the raw originalSrc the caller passed in.
|
|
const raw = "https://EXAMPLE.com/anim.gif";
|
|
const img = createFakeImg(raw);
|
|
const wrap = createWrapper();
|
|
observeMedia(img, raw, wrap);
|
|
// Sanity check: the DOM really did normalize it away from `raw`.
|
|
expect(img.src).toBe("https://example.com/anim.gif");
|
|
expect(img.src).not.toBe(raw);
|
|
|
|
const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement;
|
|
btn.click();
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
expect(wrap.classList.contains("gif-paused")).toBe(true);
|
|
cleanup();
|
|
});
|
|
|
|
it("pause button click freezes immediately", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
expect(img.src).toBe("https://example.com/cat.gif");
|
|
const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement;
|
|
btn.click();
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
cleanup();
|
|
});
|
|
|
|
it("pauseAllMedia freezes all tracked GIFs", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img1 = createFakeImg("https://example.com/a.gif");
|
|
const img2 = createFakeImg("https://example.com/b.gif");
|
|
const wrap1 = createWrapper();
|
|
const wrap2 = createWrapper();
|
|
observeMedia(img1, "https://example.com/a.gif", wrap1);
|
|
observeMedia(img2, "https://example.com/b.gif", wrap2);
|
|
pauseAllMedia();
|
|
expect(img1.src).toBe("data:image/png;base64,frozen");
|
|
expect(img2.src).toBe("data:image/png;base64,frozen");
|
|
cleanup();
|
|
});
|
|
|
|
it("resumeVisibleMedia only unfreezes intersecting GIFs", () => {
|
|
const img1 = createFakeImg("https://example.com/a.gif");
|
|
const img2 = createFakeImg("https://example.com/b.gif");
|
|
const wrap1 = createWrapper();
|
|
const wrap2 = createWrapper();
|
|
observeMedia(img1, "https://example.com/a.gif", wrap1);
|
|
observeMedia(img2, "https://example.com/b.gif", wrap2);
|
|
fireIntersection([
|
|
{ target: img1, isIntersecting: true },
|
|
{ target: img2, isIntersecting: false },
|
|
]);
|
|
img1.src = "data:image/png;base64,frozen";
|
|
img2.src = "data:image/png;base64,frozen";
|
|
resumeVisibleMedia();
|
|
expect(img1.src).toBe("https://example.com/a.gif");
|
|
expect(img2.src).toBe("data:image/png;base64,frozen");
|
|
});
|
|
|
|
it("wrapper gets gif-paused class when frozen", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
expect(wrap.classList.contains("gif-paused")).toBe(false);
|
|
vi.advanceTimersByTime(10_000);
|
|
expect(wrap.classList.contains("gif-paused")).toBe(true);
|
|
cleanup();
|
|
});
|
|
|
|
it("destroyObserver cleans up", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
destroyObserver();
|
|
expect(disconnectMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("unobserveMedia does not start a dangling auto-timer", () => {
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap);
|
|
unobserveMedia(img);
|
|
const cleanup = setupCanvasMocks();
|
|
vi.advanceTimersByTime(15_000);
|
|
// toDataURL should NOT have been called (no dangling timer)
|
|
const canvas = document.createElement("canvas");
|
|
expect(canvas.toDataURL).not.toHaveBeenCalled();
|
|
cleanup();
|
|
});
|
|
|
|
it("starts frozen when startFrozen is true", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap, true);
|
|
// Should be frozen immediately
|
|
expect(wrap.classList.contains("gif-paused")).toBe(true);
|
|
// The button should show play icon
|
|
const btn = wrap.querySelector(".gif-play-btn");
|
|
expect(btn?.querySelector('svg[data-icon="play"]')).not.toBeNull();
|
|
cleanup();
|
|
});
|
|
|
|
it("startFrozen image can be unfrozen by clicking play", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/cat.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/cat.gif", wrap, true);
|
|
|
|
expect(wrap.classList.contains("gif-paused")).toBe(true);
|
|
|
|
// Click play to unfreeze
|
|
const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
expect(img.src).toBe("https://example.com/cat.gif");
|
|
expect(wrap.classList.contains("gif-paused")).toBe(false);
|
|
cleanup();
|
|
});
|
|
|
|
it("unobserveMedia on an untracked image is a no-op", () => {
|
|
const img = createFakeImg("https://example.com/unknown.gif");
|
|
// Should not throw
|
|
unobserveMedia(img);
|
|
expect(unobserveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("pauseAllMedia cleans up stale WeakRefs", () => {
|
|
const cleanup = setupCanvasMocks();
|
|
const img = createFakeImg("https://example.com/stale.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/stale.gif", wrap);
|
|
|
|
// First, unobserve to remove from tracked but leave in allTracked
|
|
// Then pauseAllMedia should handle the missing entry gracefully
|
|
pauseAllMedia();
|
|
// Should not throw
|
|
expect(img.src).toBe("data:image/png;base64,frozen");
|
|
cleanup();
|
|
});
|
|
|
|
it("resumeVisibleMedia cleans up stale WeakRefs without throwing", () => {
|
|
// Register and immediately unobserve so the entry is gone from tracked
|
|
const img = createFakeImg("https://example.com/gone.gif");
|
|
const wrap = createWrapper();
|
|
observeMedia(img, "https://example.com/gone.gif", wrap);
|
|
unobserveMedia(img);
|
|
// Now resumeVisibleMedia should not crash
|
|
resumeVisibleMedia();
|
|
// No assertion needed — just verifying no throw
|
|
});
|
|
});
|