mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(message-list): rebuild virtual window when scroll leaves the rendered range Scroll-driven renderWindow calls previously never rebuilt the DOM, so scrolling past the overscan showed only spacer blank space until an unrelated data change forced a full re-render. The window now rebuilds whenever the computed visible range is not fully contained in the rendered one, keeping the no-op (and the existing rebuild rate limiter) for ranges that are already rendered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(messages): refetch channel tail when revisiting a previously loaded channel The server only delivers live message broadcasts for the focused channel, so a channel's loaded window stops updating once the user switches away. Switching channels now drops the left channel's loaded flag so the next visit refetches the live tail, while keeping the old rows rendered until the refetch merges in (pending/failed rows are preserved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(channel-sidebar): assign distinct slots when reordering channels with tied positions Categories whose channels share a position value (the server does not enforce uniqueness, and new channels default to position 0) previously produced an empty or partial reorder on drop, leaving the final order ambiguous. Tied slots are now nudged into a strictly increasing sequence before being reassigned, while already-distinct groups keep their range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(embeds): keep the link-preview abort timer armed until the body is read The link-preview fetch previously cleared its 5 s abort timer as soon as response headers arrived, so reading the response body was unbounded in time and size. The timer is now cleared in a finally after the body read, so the timeout covers the whole request and the 50 KB parse cap applies as documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller The two floating controls were appended inside the overflow scroller, so they were part of its scrollable overflow and translated away with the content whenever the user scrolled up — precisely when they become visible. They now anchor to a position:relative frame that wraps the scroller, keeping them pinned to the viewport edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): reset the pinned input device before cycling the mic to default Selecting the default microphone (or losing the selected one to a hot unplug) only muted and unmuted the existing track, which kept capturing from the previously pinned device. The shared cycle now resets the capture device to the system default first so both paths actually reach it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): re-check supersession after camera publish before announcing camera on A camera disable that completes while the enable's publishTrack call is still in flight now causes the enable to unpublish and stop its track and skip the enabled announcement, so the server's last word matches the local state instead of reporting a stopped camera as on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(voice): re-check supersession across the screenshare publish loop A screenshare disable that completes while a publish in the enable loop is still in flight now stops the loop before the remaining tracks are published; the enable attempt unpublishes and stops all of its tracks and skips the enabled announcement, so tracks held only by that attempt are released and the server's last word matches the local state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(sidebar): rethrow channel modal API failures so modals can recover The create/edit/delete channel callbacks caught API errors and only showed a toast, so the awaiting modal never saw the failure and left its submit button disabled with the in-flight label. The callbacks now rethrow after toasting, letting each modal re-enable its button and render its inline error so the user can retry without losing the form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(messages): carry pending and failed rows across the prepend trim When a scroll-up page pushed a channel past the per-channel cap, prependMessages trimmed the tail wholesale, deleting pending/failed optimistic rows that hold the only copy of the user's composed text. The trim now carries those rows across, matching the other window-replacing writers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag A store-driven sidebar re-render while a drag is in flight rebuilds the channel rows, detaching the container captured at mousedown; detached rows report all-zero rects, so the drop and the hover indicator could never resolve. The global handlers now re-target the live row, its container, and the store's current group snapshot before hit-testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(delete-channel-modal): re-arm the confirm button when a delete fails The confirm button was only restored from the catch block, so a caller that handled the failure itself and resolved left the button disabled on 'Deleting...' with no way to retry. Restoration now runs in a finally block whenever the modal is still open, regardless of how the callback settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(dm-sidebar): keep the presence dot when an avatar image loads The avatar swap cleared the whole circle before inserting the fetched image, which also removed the online/idle/dnd/offline dot on 1:1 rows. The initial now lives in its own node and only that node is replaced, so the presence dot survives the image arriving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(formatting): compute the yesterday boundary from the calendar date The relative-day fence post was derived by subtracting a fixed 24 hours from local midnight, which lands inside the wrong calendar day when a DST transition makes the local day 23 or 25 hours long. It is now built from the calendar date directly, so hover and expanded message timestamps keep the correct Yesterday label around transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU * fix(message-input): keep the empty-edit guard active while attachments are queued Edits are text-only, so a queued attachment no longer bypasses the empty-content guard while editing. Submitting an edit whose text was cleared is now refused with edit mode intact, matching the behavior when nothing is attached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188TWF92r4aNM7T6eiiD7sU --------- Co-authored-by: Claude <noreply@anthropic.com>
1015 lines
31 KiB
TypeScript
1015 lines
31 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { fetchMock } = vi.hoisted(() => ({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
fetchMock: vi.fn<any>(),
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/plugin-http", () => ({
|
|
fetch: fetchMock,
|
|
}));
|
|
|
|
const { mockObserveMedia } = vi.hoisted(() => ({
|
|
mockObserveMedia: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@lib/media-visibility", () => ({
|
|
observeMedia: mockObserveMedia,
|
|
}));
|
|
|
|
import {
|
|
clearEmbedCaches,
|
|
renderGenericLinkPreview,
|
|
parseOgTags,
|
|
applyOgMeta,
|
|
} from "../../src/components/message-list/embeds";
|
|
import type { OgMeta } from "../../src/components/message-list/embeds";
|
|
import { setServerHost } from "../../src/components/message-list/attachments";
|
|
|
|
function mockHtmlResponse(html: string) {
|
|
return {
|
|
ok: true,
|
|
headers: {
|
|
get(name: string) {
|
|
return name.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null;
|
|
},
|
|
},
|
|
text: vi.fn().mockResolvedValue(html),
|
|
};
|
|
}
|
|
|
|
describe("renderGenericLinkPreview", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
fetchMock.mockReset();
|
|
setServerHost("example.com");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("does not reuse OG metadata that resolves after the cache was cleared", async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let resolveFetch: ((value: any) => void) | null = null;
|
|
fetchMock.mockImplementationOnce(
|
|
(() =>
|
|
new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
})) as any,
|
|
);
|
|
|
|
const first = renderGenericLinkPreview("https://news.example.com/post");
|
|
document.body.appendChild(first);
|
|
|
|
await Promise.resolve();
|
|
clearEmbedCaches();
|
|
(resolveFetch as any)?.(mockHtmlResponse("<html><head><title>Fresh</title></head></html>"));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>Fresh</title></head></html>"),
|
|
);
|
|
const second = renderGenericLinkPreview("https://news.example.com/post");
|
|
document.body.appendChild(second);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it("does not reuse an EMPTY_OG result that resolves after the cache was cleared", async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let resolveFetch: ((value: any) => void) | null = null;
|
|
fetchMock.mockImplementationOnce(
|
|
(() =>
|
|
new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
})) as any,
|
|
);
|
|
|
|
const first = renderGenericLinkPreview("https://news.example.com/empty");
|
|
document.body.appendChild(first);
|
|
|
|
await Promise.resolve();
|
|
clearEmbedCaches();
|
|
(resolveFetch as any)?.({
|
|
ok: false,
|
|
headers: { get: () => null },
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>Recovered</title></head></html>"),
|
|
);
|
|
const second = renderGenericLinkPreview("https://news.example.com/empty");
|
|
document.body.appendChild(second);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it("keeps replacement preview requests deduplicated after a clear", async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let resolveFirst: ((value: any) => void) | null = null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let resolveSecond: ((value: any) => void) | null = null;
|
|
fetchMock
|
|
.mockImplementationOnce(
|
|
(() =>
|
|
new Promise((resolve) => {
|
|
resolveFirst = resolve;
|
|
})) as any,
|
|
)
|
|
.mockImplementationOnce(
|
|
(() =>
|
|
new Promise((resolve) => {
|
|
resolveSecond = resolve;
|
|
})) as any,
|
|
);
|
|
|
|
const first = renderGenericLinkPreview("https://news.example.com/race");
|
|
document.body.appendChild(first);
|
|
await Promise.resolve();
|
|
|
|
clearEmbedCaches();
|
|
|
|
const second = renderGenericLinkPreview("https://news.example.com/race");
|
|
document.body.appendChild(second);
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
(resolveFirst as any)?.(mockHtmlResponse("<html><head><title>Old</title></head></html>"));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const third = renderGenericLinkPreview("https://news.example.com/race");
|
|
document.body.appendChild(third);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
(resolveSecond as any)?.(mockHtmlResponse("<html><head><title>New</title></head></html>"));
|
|
await vi.waitFor(() => {
|
|
expect(second.querySelector(".msg-embed-link-title")?.textContent).toBe("New");
|
|
});
|
|
});
|
|
|
|
it("fetches OG metadata for public domains that begin with fd", async () => {
|
|
fetchMock.mockResolvedValue(
|
|
mockHtmlResponse("<html><head><title>F-Droid</title></head></html>"),
|
|
);
|
|
|
|
const card = renderGenericLinkPreview("https://fdroid.org/packages");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://fdroid.org/packages",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
"User-Agent": expect.stringContaining("facebookexternalhit"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("F-Droid");
|
|
});
|
|
});
|
|
|
|
it("blocks previews for private IPv6 literals", async () => {
|
|
for (const url of ["https://[fd00::1]/", "https://[fe80::1]/", "https://[::ffff:127.0.0.1]/"]) {
|
|
document.body.innerHTML = "";
|
|
const card = renderGenericLinkPreview(url);
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBeTruthy();
|
|
}
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks previews for loopback IPv4 literals beyond 127.0.0.1", async () => {
|
|
const card = renderGenericLinkPreview("https://127.0.0.2/internal");
|
|
document.body.appendChild(card);
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("127.0.0.2");
|
|
});
|
|
|
|
it("blocks previews for multicast, reserved, and documentation addresses", async () => {
|
|
const blockedUrls = [
|
|
"https://224.0.0.1/",
|
|
"https://239.255.255.250/",
|
|
"https://240.0.0.1/",
|
|
"https://255.255.255.255/",
|
|
"https://192.0.2.1/",
|
|
"https://198.51.100.10/",
|
|
"https://203.0.113.7/",
|
|
"https://[ff02::1]/",
|
|
"https://[2001:db8::1]/",
|
|
];
|
|
|
|
for (const url of blockedUrls) {
|
|
document.body.innerHTML = "";
|
|
const card = renderGenericLinkPreview(url);
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBeTruthy();
|
|
}
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows previews for the configured OwnCord server even on private hosts", async () => {
|
|
setServerHost("LOCALHOST:8080");
|
|
fetchMock.mockResolvedValue(
|
|
mockHtmlResponse("<html><head><title>OwnCord Local</title></head></html>"),
|
|
);
|
|
|
|
const card = renderGenericLinkPreview("https://localhost:8080/docs");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
"https://localhost:8080/docs",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
"User-Agent": expect.stringContaining("facebookexternalhit"),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("OwnCord Local");
|
|
});
|
|
});
|
|
|
|
it("blocks preview for malformed URLs", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("not-a-valid-url");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips non-HTML responses (e.g., JSON)", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
headers: {
|
|
get(name: string) {
|
|
return name.toLowerCase() === "content-type" ? "application/json" : null;
|
|
},
|
|
},
|
|
text: vi.fn().mockResolvedValue("{}"),
|
|
});
|
|
|
|
const card = renderGenericLinkPreview("https://api.example.com/data.json");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
// Title should fall back to hostname since no OG data from JSON
|
|
await vi.waitFor(() => {
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("api.example.com");
|
|
});
|
|
});
|
|
|
|
it("handles fetch error gracefully", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
const card = renderGenericLinkPreview("https://error.example.com/page");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
// Should render with hostname as fallback
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("error.example.com");
|
|
});
|
|
|
|
it("handles non-ok response", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockResolvedValueOnce({
|
|
ok: false,
|
|
headers: { get: () => null },
|
|
});
|
|
|
|
const card = renderGenericLinkPreview("https://fail.example.com/404");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("renders from cache on second call (no second fetch)", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse(
|
|
'<html><head><meta property="og:title" content="Cached Title"></head></html>',
|
|
),
|
|
);
|
|
|
|
const card1 = renderGenericLinkPreview("https://cached.example.com/page");
|
|
document.body.appendChild(card1);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(card1.querySelector(".msg-embed-link-title")?.textContent).toBe("Cached Title");
|
|
});
|
|
|
|
// Second call should use cache
|
|
const card2 = renderGenericLinkPreview("https://cached.example.com/page");
|
|
document.body.appendChild(card2);
|
|
expect(card2.querySelector(".msg-embed-link-title")?.textContent).toBe("Cached Title");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("blocks CGNAT range (100.64-127.x.x.x)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://100.64.0.1/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 172.16-31.x.x range", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://172.16.0.1/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows 172.32.x.x (not private)", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>Public</title></head></html>"),
|
|
);
|
|
const card = renderGenericLinkPreview("https://172.32.0.1/page");
|
|
document.body.appendChild(card);
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("blocks 169.254.x.x (link-local)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://169.254.1.1/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 10.x.x.x (private)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://10.0.0.1/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 0.x.x.x (unspecified)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://0.0.0.0/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 192.168.x.x (private)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://192.168.1.1/admin");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 198.18-19.x.x (benchmarking)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://198.18.0.1/benchmark");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks 192.0.x.x", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://192.0.0.1/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks IPv6 unspecified (::)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://[::]/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks IPv6 loopback (::1)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://[::1]/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks IPv6 fc00::/7 (fc prefix)", async () => {
|
|
clearEmbedCaches();
|
|
const card = renderGenericLinkPreview("https://[fc00::1]/internal");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows public IPv6 addresses", async () => {
|
|
clearEmbedCaches();
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>IPv6</title></head></html>"),
|
|
);
|
|
const card = renderGenericLinkPreview("https://[2600::1]/page");
|
|
document.body.appendChild(card);
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// Note: parseIPv4Literal's octet validation (lines 100-102) is unreachable
|
|
// because new URL() already rejects invalid IPv4 addresses before isPrivateHost
|
|
// runs. The branch is defensive coding against edge cases.
|
|
});
|
|
|
|
describe("parseOgTags", () => {
|
|
it("extracts og:title, og:description, og:image, og:site_name", () => {
|
|
const html = `<html><head>
|
|
<meta property="og:title" content="My Page">
|
|
<meta property="og:description" content="A page description">
|
|
<meta property="og:image" content="https://example.com/img.jpg">
|
|
<meta property="og:site_name" content="Example">
|
|
</head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBe("My Page");
|
|
expect(meta.description).toBe("A page description");
|
|
expect(meta.image).toBe("https://example.com/img.jpg");
|
|
expect(meta.siteName).toBe("Example");
|
|
});
|
|
|
|
it("falls back to <title> when og:title is missing", () => {
|
|
const html = `<html><head><title>Fallback Title</title></head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBe("Fallback Title");
|
|
});
|
|
|
|
it("falls back to meta description when og:description is missing", () => {
|
|
const html = `<html><head><meta name="description" content="Meta desc"></head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.description).toBe("Meta desc");
|
|
});
|
|
|
|
it("returns nulls when no metadata exists", () => {
|
|
const html = `<html><head></head><body>Hello</body></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBeNull();
|
|
expect(meta.description).toBeNull();
|
|
expect(meta.image).toBeNull();
|
|
expect(meta.siteName).toBeNull();
|
|
});
|
|
|
|
it("handles reversed attribute order (content before property)", () => {
|
|
const html = `<html><head>
|
|
<meta content="Reversed Title" property="og:title">
|
|
</head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBe("Reversed Title");
|
|
});
|
|
|
|
it("is case-insensitive for tag names", () => {
|
|
const html = `<html><head>
|
|
<META PROPERTY="og:title" CONTENT="Upper Case">
|
|
</head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBe("Upper Case");
|
|
});
|
|
|
|
it("trims whitespace from <title> tag", () => {
|
|
const html = `<html><head><title> Spaced Title </title></head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBe("Spaced Title");
|
|
});
|
|
|
|
it("ignores meta-like strings inside comments and scripts (F7: real HTML parsing, not backtracking regex)", () => {
|
|
// A real HTML tokenizer treats these as a comment node and script text, not
|
|
// <meta> elements — so attacker-controlled preview HTML can neither smuggle a
|
|
// fake OG tag nor drive a polynomial-backtracking regex. A string-matching
|
|
// regex would (wrongly) pick up "Commented Out".
|
|
const html = `<html><head>
|
|
<!-- <meta property="og:title" content="Commented Out"> -->
|
|
<script>var s = '<meta property="og:title" content="In Script">';</script>
|
|
</head></html>`;
|
|
const meta = parseOgTags(html);
|
|
expect(meta.title).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("applyOgMeta", () => {
|
|
it("sets title from meta", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = { title: "Page Title", description: null, image: null, siteName: null };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(titleEl.textContent).toBe("Page Title");
|
|
});
|
|
|
|
it("falls back to displayHost when title is null", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = { title: null, description: null, image: null, siteName: null };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(titleEl.textContent).toBe("example.com");
|
|
});
|
|
|
|
it("sets siteName when present", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = { title: "Title", description: null, image: null, siteName: "My Site" };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(hostEl.textContent).toBe("My Site");
|
|
});
|
|
|
|
it("truncates long descriptions to 200 chars", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const longDesc = "A".repeat(300);
|
|
const meta: OgMeta = { title: "Title", description: longDesc, image: null, siteName: null };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(descEl.textContent!.length).toBe(200);
|
|
expect(descEl.textContent!.endsWith("...")).toBe(true);
|
|
expect(descEl.style.display).toBe("");
|
|
});
|
|
|
|
it("shows description when present and short", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: "Short description",
|
|
image: null,
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(descEl.textContent).toBe("Short description");
|
|
expect(descEl.style.display).toBe("");
|
|
});
|
|
|
|
it("hides description when null", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = { title: "Title", description: null, image: null, siteName: null };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(descEl.style.display).toBe("none");
|
|
});
|
|
|
|
it("renders image when og:image is a valid URL", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "https://example.com/image.jpg",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
const img = imageWrap.querySelector("img");
|
|
expect(img).not.toBeNull();
|
|
expect(img?.getAttribute("src")).toBe("https://example.com/image.jpg");
|
|
expect(imageWrap.style.display).toBe("");
|
|
});
|
|
|
|
it("resolves relative image URLs", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "/images/og.png",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
const img = imageWrap.querySelector("img");
|
|
expect(img).not.toBeNull();
|
|
expect(img?.getAttribute("src")).toBe("https://example.com/images/og.png");
|
|
});
|
|
|
|
it("hides image on error", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "https://example.com/image.jpg",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
const img = imageWrap.querySelector("img")!;
|
|
img.dispatchEvent(new Event("error"));
|
|
|
|
expect(imageWrap.style.display).toBe("none");
|
|
});
|
|
|
|
it("does not render image when og:image is empty string", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = { title: "Title", description: null, image: "", siteName: null };
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(imageWrap.querySelector("img")).toBeNull();
|
|
});
|
|
|
|
it("blocks private host images", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "https://192.168.1.1/image.png",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
expect(imageWrap.querySelector("img")).toBeNull();
|
|
});
|
|
|
|
it("adds crossorigin attribute for GIF images", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "https://example.com/animated.gif",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
const img = imageWrap.querySelector("img");
|
|
expect(img).not.toBeNull();
|
|
expect(img?.getAttribute("crossorigin")).toBe("anonymous");
|
|
});
|
|
|
|
it("calls observeMedia on GIF image load event", () => {
|
|
const titleEl = document.createElement("a");
|
|
const descEl = document.createElement("div");
|
|
const hostEl = document.createElement("div");
|
|
const imageWrap = document.createElement("div");
|
|
|
|
const meta: OgMeta = {
|
|
title: "Title",
|
|
description: null,
|
|
image: "https://example.com/animated.gif",
|
|
siteName: null,
|
|
};
|
|
applyOgMeta(
|
|
meta,
|
|
titleEl,
|
|
descEl,
|
|
hostEl,
|
|
imageWrap,
|
|
"https://example.com/page",
|
|
"example.com",
|
|
);
|
|
|
|
const img = imageWrap.querySelector("img")!;
|
|
img.dispatchEvent(new Event("load"));
|
|
|
|
expect(mockObserveMedia).toHaveBeenCalledWith(
|
|
img,
|
|
"https://example.com/animated.gif",
|
|
imageWrap,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("renderGenericLinkPreview — fetch timeout covers the body read", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
fetchMock.mockReset();
|
|
clearEmbedCaches();
|
|
setServerHost("example.com");
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
function mockSlowBodyResponse() {
|
|
let capturedSignal: AbortSignal | undefined;
|
|
fetchMock.mockImplementationOnce(((_url: string, opts: RequestInit) => {
|
|
capturedSignal = opts.signal as AbortSignal;
|
|
return Promise.resolve({
|
|
ok: true,
|
|
headers: {
|
|
get: (name: string) =>
|
|
name.toLowerCase() === "content-type" ? "text/html; charset=utf-8" : null,
|
|
},
|
|
// Body that never finishes on its own; rejects if the signal aborts,
|
|
// mirroring fetch semantics for an aborted in-progress body read.
|
|
text: () =>
|
|
new Promise<string>((_resolve, reject) => {
|
|
const fail = () => reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
if (capturedSignal!.aborted) fail();
|
|
else capturedSignal!.addEventListener("abort", fail, { once: true });
|
|
}),
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
}) as any);
|
|
return () => capturedSignal;
|
|
}
|
|
|
|
it("aborts when the body never finishes within the 5 s timeout", async () => {
|
|
const getSignal = mockSlowBodyResponse();
|
|
|
|
const card = renderGenericLinkPreview("https://slow-body.example.com/page");
|
|
document.body.appendChild(card);
|
|
|
|
// Let the header phase resolve and the body read begin.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
|
|
expect(getSignal()?.aborted).toBe(true);
|
|
|
|
// The aborted fetch settles as an empty result: the card keeps its
|
|
// hostname fallback and the URL is cached without a refetch loop.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("slow-body.example.com");
|
|
|
|
const again = renderGenericLinkPreview("https://slow-body.example.com/page");
|
|
document.body.appendChild(again);
|
|
expect(again.querySelector(".msg-embed-link-title")?.textContent).toBe("slow-body.example.com");
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not abort when the body arrives before the timeout", async () => {
|
|
let capturedSignal: AbortSignal | undefined;
|
|
fetchMock.mockImplementationOnce(((_url: string, opts: RequestInit) => {
|
|
capturedSignal = opts.signal as AbortSignal;
|
|
return Promise.resolve(mockHtmlResponse("<html><head><title>Timely</title></head></html>"));
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
}) as any);
|
|
|
|
const card = renderGenericLinkPreview("https://timely.example.com/page");
|
|
document.body.appendChild(card);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(card.querySelector(".msg-embed-link-title")?.textContent).toBe("Timely");
|
|
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
expect(capturedSignal?.aborted).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("renderGenericLinkPreview — cache stale during non-HTML response", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
fetchMock.mockReset();
|
|
clearEmbedCaches();
|
|
setServerHost("example.com");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("discards non-HTML response result when cache was cleared mid-flight", async () => {
|
|
let resolveFetch: ((value: unknown) => void) | null = null;
|
|
fetchMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
const card = renderGenericLinkPreview("https://stale-nonhtml.example.com/data");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
|
|
// Clear cache while fetch is in-flight
|
|
clearEmbedCaches();
|
|
|
|
// Resolve with non-HTML content type
|
|
(resolveFetch as any)?.({
|
|
ok: true,
|
|
headers: {
|
|
get: (name: string) => (name.toLowerCase() === "content-type" ? "application/json" : null),
|
|
},
|
|
text: vi.fn().mockResolvedValue("{}"),
|
|
});
|
|
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// Should have discarded the result (generation mismatch)
|
|
// A new fetch for the same URL should still trigger a new fetch
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>Fresh</title></head></html>"),
|
|
);
|
|
const card2 = renderGenericLinkPreview("https://stale-nonhtml.example.com/data");
|
|
document.body.appendChild(card2);
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
it("discards fetch error result when cache was cleared mid-flight", async () => {
|
|
let rejectFetch: ((err: Error) => void) | null = null;
|
|
fetchMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise((_resolve, reject) => {
|
|
rejectFetch = reject;
|
|
}),
|
|
);
|
|
|
|
const card = renderGenericLinkPreview("https://stale-error.example.com/fail");
|
|
document.body.appendChild(card);
|
|
await Promise.resolve();
|
|
|
|
// Clear cache while fetch is in-flight
|
|
clearEmbedCaches();
|
|
|
|
// Reject the fetch
|
|
(rejectFetch as any)?.(new Error("network error"));
|
|
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
// A new fetch for the same URL should still trigger a new fetch
|
|
fetchMock.mockResolvedValueOnce(
|
|
mockHtmlResponse("<html><head><title>Recovered</title></head></html>"),
|
|
);
|
|
const card2 = renderGenericLinkPreview("https://stale-error.example.com/fail");
|
|
document.body.appendChild(card2);
|
|
await vi.waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|