Add cross-browser popup status handling

This commit is contained in:
Jordan Wages 2026-08-24 23:32:27 -05:00
commit 7e17470356
38 changed files with 1242 additions and 199 deletions

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine";
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
import type { SiteDefinition } from "../src/shared/types";
const definition: SiteDefinition = {
id: "example-social",
name: "Example Social",
urlPatterns: ["https://*.example.test/*"],
requiredSelectors: [".feed"],
post: { rootSelectors: ["article.post"], textSelectors: [".content"], excludedSelectors: [".exclude"], idAttributes: ["data-id"], permalinkSelectors: ["a.permalink[href]"] }
};
describe("DefinitionEngine", () => {
it("discovers the supplied root, extracts only visible text, and uses a stable ID", () => {
document.body.innerHTML = '<article class="post" data-id="42"><div class="content">Visible <span class="exclude">skip</span><span style="display:none">hidden</span></div></article>';
const post = new DefinitionEngine(definition).discover(document.querySelector("article")!)[0];
expect(post).toMatchObject({ id: "example-social:42", text: "visible" });
});
it("uses permalink IDs and selects only definitions with URL and marker matches", () => {
document.body.innerHTML = '<div class="feed"><article class="post"><div class="content">Hello</div><a class="permalink" href="/users/a/statuses/1">link</a></article></div>';
const engine = new DefinitionEngine(definition);
expect(engine.discover(document)[0]?.id).toContain("/users/a/statuses/1");
expect(selectDefinition([definition], "https://social.example.test/home")).toBe(definition);
expect(selectDefinition([definition], "https://other.test/home")).toBeUndefined();
});
it("matches browser-style URL patterns", () => {
expect(matchesUrlPattern("https://*.example.test/*", "https://a.example.test/path?q=1")).toBe(true);
expect(matchesUrlPattern("https://*.example.test/*", "http://a.example.test/path")).toBe(false);
});
it("covers Mastodon timeline, profile, and detailed reply status containers", () => {
document.body.innerHTML = `
<div class="status" data-id="timeline"><div class="status__content">Timeline post</div></div>
<div class="status" data-id="profile"><div class="status__content">Profile post</div></div>
<div class="detailed-status"><a class="detailed-status__datetime" href="/users/a/statuses/3"></a><div class="status__content">Reply in thread</div></div>`;
const posts = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!).discover(document);
expect(posts.map((post) => post.id)).toEqual(["mastodon:timeline", "mastodon:profile", "mastodon:http://localhost:3000/users/a/statuses/3"]);
expect(posts.map((post) => post.text)).toEqual(["timeline post", "profile post", "reply in thread"]);
});
it("does not include hidden Mastodon content-warning text until it is revealed", () => {
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning <a class="status__content__spoiler-link">Show more</a></p><div id="hidden" style="display:none">Hidden post text</div></div></div>';
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
expect(engine.discover(document)[0]?.text).toBe("content warning");
document.querySelector("#hidden")?.removeAttribute("style");
expect(engine.discover(document)[0]?.text).toBe("content warning hidden post text");
});
it("rediscovers a Mastodon status when a content warning is revealed", async () => {
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning</p><div id="hidden" style="display:none">Revealed text</div></div></div>';
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
const observed: string[] = [];
const stop = engine.observe((posts) => observed.push(...posts.map((post) => post.text)));
document.querySelector("#hidden")?.removeAttribute("style");
await new Promise((resolve) => setTimeout(resolve, 0));
stop();
expect(observed).toContain("content warning revealed text");
});
});

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import { applyResult, restore } from "../src/content/filter";
import { afterEach, describe, expect, it } from "vitest";
import { applyResult, removeOrphanedPlaceholders, restore } from "../src/content/filter";
import { DEFAULT_SETTINGS } from "../src/shared/types";
describe("content filtering", () => {
afterEach(() => document.body.replaceChildren());
it("collapses toxic content and restores it", () => {
const element = document.createElement("article");
document.body.append(element);
@ -13,4 +15,27 @@ describe("content filtering", () => {
expect(element.style.display).toBe("");
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
});
it("preserves the original display value when filtering is reconciled repeatedly", () => {
const element = document.createElement("article");
document.body.append(element);
const result = { requestId: "1", id: "1", textHash: "x", label: "toxic" as const, probability: .91, navigationId: "n" };
applyResult(element, result, DEFAULT_SETTINGS);
applyResult(element, result, DEFAULT_SETTINGS);
restore(element);
expect(element.style.display).toBe("");
});
it("removes placeholders no longer paired with the current post element", () => {
const stale = document.createElement("div");
const orphan = document.createElement("div");
orphan.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
const current = document.createElement("article");
const currentPlaceholder = document.createElement("div");
currentPlaceholder.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
document.body.append(stale, orphan, current, currentPlaceholder);
removeOrphanedPlaceholders("mastodon:1", [current]);
expect(orphan.isConnected).toBe(false);
expect(currentPlaceholder.isConnected).toBe(true);
});
});

20
tests/settings.test.ts Normal file
View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { setDefinitionPaused } from "../src/shared/settings";
import { DEFAULT_SETTINGS } from "../src/shared/types";
describe("definition pause settings", () => {
it("adds and removes a definition ID without disturbing other settings", () => {
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, threshold: .65 }, "mastodon", true);
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
expect(settings.threshold).toBe(.65);
const resumed = setDefinitionPaused(settings, "mastodon", false);
expect(resumed.disabledDefinitionIds).toEqual([]);
expect(resumed.threshold).toBe(.65);
});
it("does not duplicate an already paused definition", () => {
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, disabledDefinitionIds: ["mastodon"] }, "mastodon", true);
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
});
});

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { BUILTIN_DEFINITIONS, effectiveDefinitions } from "../src/content/definitions";
import { validateDefinitionConfiguration } from "../src/shared/site-definitions";
const customDefinition = {
id: "example-social",
name: "Example Social",
urlPatterns: ["https://*.example.test/*"],
requiredSelectors: [".feed"],
post: { rootSelectors: ["article"], textSelectors: [".content"], idAttributes: ["data-id"] }
};
describe("site definitions", () => {
it("accepts declarative custom definitions and deduplicates disabled IDs", () => {
const result = validateDefinitionConfiguration({ customDefinitions: [customDefinition], disabledDefinitionIds: ["mastodon", "mastodon"] });
expect(result.valid).toBe(true);
if (result.valid) expect(result.value.disabledDefinitionIds).toEqual(["mastodon"]);
});
it("rejects executable or malformed definition fields", () => {
const result = validateDefinitionConfiguration({ customDefinitions: [{ ...customDefinition, id: "Bad ID", post: { ...customDefinition.post, rootSelectors: ["["] } }], disabledDefinitionIds: [] });
expect(result.valid).toBe(false);
});
it("lets a custom definition replace a bundled definition by ID", () => {
const override = { ...BUILTIN_DEFINITIONS[0]!, name: "Custom Mastodon" };
const definitions = effectiveDefinitions([override], []);
expect(definitions.filter((definition) => definition.id === "mastodon")).toEqual([override]);
});
});