diff --git a/DESIGN.md b/DESIGN.md index 2b92d31..0effa5d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,7 +57,9 @@ Transformers.js / ONNX Runtime Web WASM CPU ``` -WebGPU inference may be investigated later, but CPU/WASM should be preferred initially to maximize browser compatibility and avoid unnecessary GPU utilization. +CPU/WASM is used for browser compatibility and to avoid unnecessary GPU utilization. + +CPU WASM inference uses ONNX Runtime's browser-selected defaults. ## Site Integration @@ -314,7 +316,6 @@ Downloading the model separately may substantially reduce extension package size Possible later improvements include: * Additional supported websites. -* WebGPU acceleration. * Improved batching and scheduling. * Persistent classification cache. * Per-site thresholds. diff --git a/README.md b/README.md index bc93800..06700ff 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ VibeGuard is a local-first browser extension that hides toxic social-media posts ## Features - Local toxicity classification using Transformers.js and a quantized ONNX model. +- Local CPU/WASM inference with ONNX Runtime. - Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely. - Optional toxicity scores on filtered-post placeholders. - Declarative site definitions with URL patterns, selectors, and stable identifiers. @@ -66,6 +67,8 @@ Supported page The model is loaded once by the shared inference backend for each browser context. Content scripts send normalized text to the background runtime and apply the returned score to the matching DOM element. Results are cached in memory to avoid repeating work when dynamic sites recreate elements. +Inference runs locally through ONNX Runtime's WASM backend using the bundled quantized model. + ## Options page Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section manages JSON subscriptions, which refresh automatically once per week and can also be refreshed manually. Local overrides can be imported from or exported to JSON and are validated before they are saved. A failed or invalid subscription update leaves the last-known-good definitions active. diff --git a/src/background/firefox.ts b/src/background/firefox.ts index bbfccb2..65c9436 100644 --- a/src/background/firefox.ts +++ b/src/background/firefox.ts @@ -1,15 +1,7 @@ import { createRuntime } from "./runtime"; -import type { InferenceResult } from "../shared/types"; +import { InferenceWorkerHost } from "./inference-worker"; -const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }); const modelBaseUrl = chrome.runtime.getURL("models/toxicity/"); +const inferenceWorker = new InferenceWorkerHost(() => new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }), modelBaseUrl); -createRuntime((requests) => new Promise((resolve, reject) => { - const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => { - worker.removeEventListener("message", listener); - if (event.data.error) reject(new Error(event.data.error)); - else resolve(event.data.results ?? []); - }; - worker.addEventListener("message", listener); - worker.postMessage({ requests, modelBaseUrl }); -})); +createRuntime((requests) => inferenceWorker.run(requests)); diff --git a/src/background/inference-worker.ts b/src/background/inference-worker.ts new file mode 100644 index 0000000..adc62bf --- /dev/null +++ b/src/background/inference-worker.ts @@ -0,0 +1,47 @@ +import type { InferenceRequest, InferenceResult } from "../shared/types"; + +export interface InferenceWorker { + addEventListener(type: "message" | "error", listener: EventListener): void; + removeEventListener(type: "message" | "error", listener: EventListener): void; + postMessage(message: { requests: InferenceRequest[]; modelBaseUrl: string }): void; + terminate(): void; +} + +type WorkerFactory = () => InferenceWorker; +type WorkerReply = { results?: InferenceResult[]; error?: string }; + +/** Keeps ONNX Runtime configuration immutable by replacing the worker on change. */ +export class InferenceWorkerHost { + private worker: InferenceWorker | undefined; + constructor(private readonly createWorker: WorkerFactory, private readonly modelBaseUrl: string) {} + + run(requests: InferenceRequest[]): Promise { + if (!this.worker) this.worker = this.createWorker(); + const worker = this.worker; + return new Promise((resolve, reject) => { + const complete = (): void => { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + }; + const onMessage: EventListener = (event) => { + const reply = (event as MessageEvent).data; + complete(); + if (reply.error) reject(new Error(reply.error)); + else resolve(reply.results ?? []); + }; + const onError: EventListener = (event) => { + complete(); + const error = event as ErrorEvent; + reject(new Error(error.message || "Inference worker failed")); + }; + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); + worker.postMessage({ requests, modelBaseUrl: this.modelBaseUrl }); + }); + } + + dispose(): void { + this.worker?.terminate(); + this.worker = undefined; + } +} diff --git a/src/background/offscreen.ts b/src/background/offscreen.ts index ee184d8..782af78 100644 --- a/src/background/offscreen.ts +++ b/src/background/offscreen.ts @@ -1,16 +1,13 @@ +import { InferenceWorkerHost } from "./inference-worker"; import type { RuntimeMessage } from "../shared/types"; -const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }); const modelBaseUrl = chrome.runtime.getURL("models/toxicity/"); +const inferenceWorker = new InferenceWorkerHost(() => new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }), modelBaseUrl); chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => { if (message.type !== "OFFSCREEN_INFER") return false; - const listener = (event: MessageEvent<{ results?: unknown; error?: string }>) => { - worker.removeEventListener("message", listener); - if (event.data.error) sendResponse({ error: event.data.error }); - else sendResponse({ results: event.data.results }); - }; - worker.addEventListener("message", listener); - worker.postMessage({ requests: message.requests, modelBaseUrl }); + inferenceWorker.run(message.requests) + .then((results) => sendResponse({ results })) + .catch((error: unknown) => sendResponse({ error: error instanceof Error ? error.message : String(error) })); return true; }); diff --git a/src/background/runtime.ts b/src/background/runtime.ts index 9a591c7..990bfe2 100644 --- a/src/background/runtime.ts +++ b/src/background/runtime.ts @@ -4,11 +4,7 @@ import { refreshSubscriptions, subscriptionsAreDue } from "../shared/subscriptio import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types"; export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise): void { - let sequence = 0; - const queue = new InferenceQueue(async (requests) => { - const requestId = ++sequence; - return runBatch(requests); - }); + const queue = new InferenceQueue(runBatch); const refreshIfDue = async (): Promise => { const settings = await loadSettings(); diff --git a/src/content/definition-engine.ts b/src/content/definition-engine.ts index 95aef2f..02b4476 100644 --- a/src/content/definition-engine.ts +++ b/src/content/definition-engine.ts @@ -99,7 +99,23 @@ export class DefinitionEngine { } export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined { - return definitions.find((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null)); + return definitions.find((definition) => matchesDefinition(definition, currentUrl, root)); +} + +/** Select a definition from the URL alone. Useful while an SPA is still hydrating. */ +export function selectDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): SiteDefinition | undefined { + // A broad definition (such as Mastodon) still needs its marker + // selectors; otherwise it would claim every page during startup. + return definitions.find((definition) => definition.urlPatterns.some((pattern) => pattern !== "") && matchesAnyUrlPattern(definition.urlPatterns, currentUrl)); +} + +/** Whether a definition might apply after an SPA finishes rendering its markers. */ +export function hasPotentialDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): boolean { + return definitions.some((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl)); +} + +function matchesDefinition(definition: SiteDefinition, currentUrl: string, root: Document): boolean { + return matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null); } export function matchesAnyUrlPattern(patterns: string[], url: string): boolean { diff --git a/src/content/definitions/default.json b/src/content/definitions/default.json index 6a235ab..d66b17d 100644 --- a/src/content/definitions/default.json +++ b/src/content/definitions/default.json @@ -26,9 +26,9 @@ "https://threads.net/*", "https://www.threads.net/*" ], - "requiredSelectors": ["[role=\"article\"], article"], + "requiredSelectors": ["[role=\"article\"], article, div[data-pagelet^=\"threads_post_page_\"]"], "post": { - "rootSelectors": ["[role=\"article\"]", "article"], + "rootSelectors": ["[role=\"article\"]", "article", "div[data-pagelet^=\"threads_post_page_\"]"], "textSelectors": ["span[dir=\"auto\"]"], "excludedSelectors": ["button", "[role=\"button\"]", "time", "svg", "a span", "time span", "[role=\"button\"] span"], "permalinkSelectors": ["a[href*=\"/post/\"]"] diff --git a/src/content/main.ts b/src/content/main.ts index 9d6bba7..b8588a1 100644 --- a/src/content/main.ts +++ b/src/content/main.ts @@ -1,10 +1,10 @@ import { effectiveDefinitions } from "./definitions"; -import { DefinitionEngine, selectDefinition } from "./definition-engine"; +import { DefinitionEngine, hasPotentialDefinitionForUrl, selectDefinition, selectDefinitionForUrl } from "./definition-engine"; import { applyResult, hasAppliedFilter, removeOrphanedPlaceholders, restore } from "./filter"; import { loadSettings } from "../shared/settings"; import { hashText } from "../shared/hash"; import { priorityFor } from "../inference/queue"; -import type { InferenceRequest, PageStatus, RuntimeMessage, Settings } from "../shared/types"; +import type { InferenceRequest, InferenceResponse, PageStatus, RuntimeMessage, Settings } from "../shared/types"; void start(); @@ -45,10 +45,37 @@ async function start(): Promise { } function runDefinition(settings: Settings, sequence: number): () => void { - const definition = selectDefinition(effectiveDefinitions(settings)); - if (!definition) return () => {}; - console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href }); - const parser = new DefinitionEngine(definition); + const definitions = effectiveDefinitions(settings); + let stopCurrentDefinition = noop; + let stopped = false; + const definitionObserver = new MutationObserver(() => { + if (stopped || stopCurrentDefinition !== noop) return; + const definition = selectDefinition(definitions); + if (definition) { + stopCurrentDefinition = startDefinition(definition); + definitionObserver.disconnect(); + } + }); + + const initialDefinition = selectDefinition(definitions); + if (initialDefinition) { + stopCurrentDefinition = startDefinition(initialDefinition); + } else if (hasPotentialDefinitionForUrl(definitions)) { + // Social sites can add their marker elements after document_idle. This also + // covers instance-based sites (such as Mastodon) whose URL pattern is broad. + definitionObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true }); + } + + return () => { + stopped = true; + definitionObserver.disconnect(); + stopCurrentDefinition(); + }; + + function startDefinition(definition: ReturnType): () => void { + if (!definition) return noop; + console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href }); + const parser = new DefinitionEngine(definition); const navigationId = crypto.randomUUID(); const fingerprints = new WeakMap(); const results = new Map[1]>(); @@ -104,11 +131,23 @@ function runDefinition(settings: Settings, sequence: number): () => void { priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"), requestId: crypto.randomUUID() }; - void chrome.runtime.sendMessage[1] }>({ type: "INFER", request }) + void chrome.runtime.sendMessage({ type: "INFER", request }) .then((response) => { + if (response && "error" in response) { + console.error("[VibeGuard] Post analysis failed", { definitionId: definition.id, id: post.id, requestId: request.requestId, error: response.error }); + return; + } if (stopped || sequence !== currentReloadSequence || !response?.result || !post.element.isConnected) return; results.set(post.id, response.result); reconcileFilters(); + }) + .catch((error: unknown) => { + console.error("[VibeGuard] Post analysis failed", { + definitionId: definition.id, + id: post.id, + requestId: request.requestId, + error: error instanceof Error ? error.message : String(error) + }); }); }); reconcileFilters(); @@ -116,17 +155,21 @@ function runDefinition(settings: Settings, sequence: number): () => void { process(parser.discover(document), "initial"); const stopObserver = parser.observe((posts) => process(posts, "mutation")); - return () => { + return () => { stopped = true; stopObserver(); parser.dispose(); }; + } } +const noop = (): void => {}; + let currentReloadSequence = 0; async function getPageStatus(settings: Settings): Promise { - const definition = selectDefinition(effectiveDefinitions({ ...settings, disabledDefinitionIds: [] })); + const definitions = effectiveDefinitions({ ...settings, disabledDefinitionIds: [] }); + const definition = selectDefinition(definitions) ?? selectDefinitionForUrl(definitions); if (!definition) { console.debug("[VibeGuard] No matching definition for page status", { url: location.href }); return { supported: false, paused: false }; diff --git a/src/inference/classifier.ts b/src/inference/classifier.ts index 933dcaa..1556a9a 100644 --- a/src/inference/classifier.ts +++ b/src/inference/classifier.ts @@ -4,7 +4,6 @@ import type { InferenceRequest, InferenceResult } from "../shared/types"; import { loadModelManifest, type ModelManifest } from "./model-metadata"; let classifier: TextClassificationPipeline | undefined; -let classifierPromise: Promise | undefined; let manifest: ModelManifest | undefined; // Transformers.js disables local files in browser workers by default. Our model @@ -44,8 +43,7 @@ export async function classify(requests: InferenceRequest[], modelBaseUrl: strin async function loadClassifier(modelBaseUrl: string): Promise { if (!env.backends.onnx.wasm) throw new Error("ONNX WASM backend is unavailable."); env.backends.onnx.wasm.wasmPaths = { wasm: new URL("../../ort/ort-wasm-simd-threaded.jsep.wasm", modelBaseUrl).href }; - classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string; local_files_only: boolean }) => Promise)("text-classification", modelBaseUrl, { device: "wasm", local_files_only: true }); - return classifierPromise; + return (pipeline as unknown as (task: string, model: string, options: { device: "wasm"; dtype: "q8"; local_files_only: boolean }) => Promise)("text-classification", modelBaseUrl, { device: "wasm", dtype: "q8", local_files_only: true }); } export function resolveOutputIndex(label: string | undefined, metadata: ModelManifest): number | undefined { diff --git a/src/options/main.ts b/src/options/main.ts index f290e42..ef2bd59 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -65,6 +65,7 @@ form?.addEventListener("submit", async (event) => { } catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); } }); + importButton?.addEventListener("click", () => definitionFile?.click()); definitionFile?.addEventListener("change", async () => { const file = definitionFile.files?.[0]; diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 3a60916..4bf12b7 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -42,18 +42,23 @@ function setStorageItem(items: Record): Promise { }); } -export function mergeSettings(input?: Partial): Settings { - const subscriptions = input?.subscriptions?.length ? input.subscriptions : [DEFAULT_SUBSCRIPTION]; +type StoredSettings = Partial & { cpuThreads?: unknown; inferenceDevice?: unknown }; + +export function mergeSettings(input?: StoredSettings): Settings { + // CPU thread selection was removed. Do not carry the legacy value forward + // when an existing installation saves its settings. + const { cpuThreads: _legacyCpuThreads, inferenceDevice: _legacyInferenceDevice, ...current } = input ?? {}; + const subscriptions = current.subscriptions?.length ? current.subscriptions : [DEFAULT_SUBSCRIPTION]; const definitionConfiguration = validateDefinitionConfiguration({ - customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions, - disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds, + customDefinitions: current.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions, + disabledDefinitionIds: current.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds, subscriptions, - subscriptionCollections: input?.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections + subscriptionCollections: current.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections }); return { ...DEFAULT_SETTINGS, - ...input, - threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1), + ...current, + threshold: clamp(Number(current.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1), customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions, disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds, subscriptions: definitionConfiguration.valid ? definitionConfiguration.value.subscriptions : [DEFAULT_SUBSCRIPTION], diff --git a/src/shared/types.ts b/src/shared/types.ts index 668d101..c7dc4e6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -69,6 +69,10 @@ export interface InferenceResult { modelDurationMs?: number; } +export type InferenceResponse = + | { type: "INFERENCE_RESULT"; result: InferenceResult } + | { error: string }; + export interface Settings { threshold: number; filterMode: FilterMode; diff --git a/tests/definition-engine.test.ts b/tests/definition-engine.test.ts index 128e966..f151370 100644 --- a/tests/definition-engine.test.ts +++ b/tests/definition-engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine"; +import { DefinitionEngine, hasPotentialDefinitionForUrl, matchesUrlPattern, selectDefinition, selectDefinitionForUrl } from "../src/content/definition-engine"; import { BUILTIN_DEFINITIONS } from "../src/content/definitions"; import type { SiteDefinition } from "../src/shared/types"; @@ -84,6 +84,17 @@ describe("DefinitionEngine", () => { expect(posts.map((post) => post.text)).toEqual(["first threads post with visible text", "a reply in the conversation"]); }); + it("discovers Threads detail-page posts from pagelet containers", () => { + document.body.innerHTML = readFileSync("tests/fixtures/threads-post-page.html", "utf8"); + const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!; + const posts = new DefinitionEngine(threads).discover(document); + expect(posts.map((post) => post.id)).toEqual([ + "threads:http://localhost:3000/@alice/post/ABC123", + "threads:http://localhost:3000/@bob/post/DEF456" + ]); + expect(posts.map((post) => post.text)).toEqual(["the main post on the threads detail page", "a reply on the threads detail page"]); + }); + it("selects Threads on current and legacy domains", () => { const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!; document.body.innerHTML = '
'; @@ -92,4 +103,23 @@ describe("DefinitionEngine", () => { } expect(selectDefinition([threads], "https://example.com/home")).toBeUndefined(); }); + + it("can identify a specific site while its SPA markers are still loading", () => { + const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!; + document.body.innerHTML = ""; + expect(selectDefinition([threads], "https://www.threads.com/")).toBeUndefined(); + expect(selectDefinitionForUrl([BUILTIN_DEFINITIONS[0]!, threads], "https://www.threads.com/")).toBe(threads); + }); + + it("keeps broad definitions pending until their hydration markers appear", () => { + const mastodon = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "mastodon")!; + const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!; + document.body.innerHTML = ""; + expect(selectDefinition([mastodon], "https://mastodon.example/@user")).toBeUndefined(); + expect(selectDefinitionForUrl([mastodon], "https://mastodon.example/@user")).toBeUndefined(); + expect(hasPotentialDefinitionForUrl([mastodon], "https://mastodon.example/@user")).toBe(true); + expect(hasPotentialDefinitionForUrl([threads], "https://example.com/")).toBe(false); + document.body.innerHTML = '
Ready
'; + expect(selectDefinition([mastodon], "https://mastodon.example/@user")).toBe(mastodon); + }); }); diff --git a/tests/fixtures/threads-post-page.html b/tests/fixtures/threads-post-page.html new file mode 100644 index 0000000..a4c2826 --- /dev/null +++ b/tests/fixtures/threads-post-page.html @@ -0,0 +1,10 @@ +
+
+ 12h + The main post on the Threads detail page +
+
+ 1h + A reply on the Threads detail page +
+
diff --git a/tests/inference-worker.test.ts b/tests/inference-worker.test.ts new file mode 100644 index 0000000..6e14713 --- /dev/null +++ b/tests/inference-worker.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { InferenceWorkerHost, type InferenceWorker } from "../src/background/inference-worker"; +import type { InferenceRequest } from "../src/shared/types"; + +const request: InferenceRequest = { id: "post", text: "text", site: "threads", navigationId: "nav", priority: 0, requestId: "request" }; +class FakeWorker implements InferenceWorker { + readonly listeners = new Map(); + readonly requests: Array<{ requests: InferenceRequest[]; modelBaseUrl: string }> = []; + terminated = false; + + addEventListener(type: "message" | "error", listener: EventListener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type: "message" | "error", listener: EventListener): void { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener)); + } + + postMessage(message: { requests: InferenceRequest[]; modelBaseUrl: string }): void { + this.requests.push(message); + } + + terminate(): void { this.terminated = true; } + + reply(): void { + const event = new MessageEvent("message", { data: { results: [{ requestId: request.requestId, id: request.id, textHash: "hash", label: "not_toxic", probability: .1, navigationId: request.navigationId }] } }); + this.listeners.get("message")?.forEach((listener) => listener(event)); + } +} + +describe("InferenceWorkerHost", () => { + it("reuses its worker", async () => { + const workers: FakeWorker[] = []; + const host = new InferenceWorkerHost(() => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, "chrome-extension://id/models/toxicity/"); + + const first = host.run([request]); + workers[0]!.reply(); + await first; + const second = host.run([request]); + workers[0]!.reply(); + await second; + expect(workers).toHaveLength(1); + + expect(workers[0]!.requests).toHaveLength(2); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index b77c961..82b2932 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { setDefinitionPaused } from "../src/shared/settings"; +import { mergeSettings, setDefinitionPaused } from "../src/shared/settings"; import { DEFAULT_SETTINGS } from "../src/shared/types"; describe("definition pause settings", () => { @@ -18,3 +18,10 @@ describe("definition pause settings", () => { expect(settings.disabledDefinitionIds).toEqual(["mastodon"]); }); }); + +describe("legacy settings", () => { + it("removes legacy inference settings", () => { + expect(mergeSettings({ cpuThreads: 99 })).not.toHaveProperty("cpuThreads"); + expect(mergeSettings({ inferenceDevice: "webgpu" } as never)).not.toHaveProperty("inferenceDevice"); + }); +});