From 1739adbf76dfea8922260b47a68c49c9c1a51762 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 13:34:35 +0200 Subject: [PATCH 1/6] feat: add personalization ranking policy --- .../personalization/PersonalizationPolicy.ts | 195 ++++++++++++++++++ .../personalization/PersonalizationRanker.ts | 48 +++++ src/core/domain/personalization/types.ts | 42 ++++ tests/PersonalizationPolicy.test.ts | 82 ++++++++ tests/PersonalizationRanker.test.ts | 74 +++++++ 5 files changed, 441 insertions(+) create mode 100644 src/core/domain/personalization/PersonalizationPolicy.ts create mode 100644 src/core/domain/personalization/PersonalizationRanker.ts create mode 100644 src/core/domain/personalization/types.ts create mode 100644 tests/PersonalizationPolicy.test.ts create mode 100644 tests/PersonalizationRanker.test.ts diff --git a/src/core/domain/personalization/PersonalizationPolicy.ts b/src/core/domain/personalization/PersonalizationPolicy.ts new file mode 100644 index 00000000..78f34801 --- /dev/null +++ b/src/core/domain/personalization/PersonalizationPolicy.ts @@ -0,0 +1,195 @@ +import { SUPPORTED_LANGUAGES } from "../lang"; +import type { + PersonalizationRecentEvent, + PersonalizationStoreV1, + PersonalizationWord, +} from "./types"; + +export const PERSONALIZATION_STORE_VERSION = 1 as const; +export const PERSONALIZATION_DECAY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; +export const PERSONALIZATION_PROMOTION_THRESHOLD = 2; +export const PERSONALIZATION_MAX_WORDS_PER_LANGUAGE = 500; +export const PERSONALIZATION_MAX_RECENT_EVENTS = 100; + +const EMPTY_STORE: PersonalizationStoreV1 = { + version: PERSONALIZATION_STORE_VERSION, + languages: {}, + recentEvents: {}, +}; + +export function createEmptyPersonalizationStore(): PersonalizationStoreV1 { + return { + version: EMPTY_STORE.version, + languages: {}, + recentEvents: {}, + }; +} + +export function isPersonalizationLanguage(language: unknown): language is string { + return ( + typeof language === "string" && + language !== "auto_detect" && + language !== "textExpander" && + language in SUPPORTED_LANGUAGES + ); +} + +export function normalizePersonalizationWord( + value: unknown, + language: string, +): { normalizedWord: string; display: string } | null { + if (typeof value !== "string" || !isPersonalizationLanguage(language)) { + return null; + } + + const display = value.replace(/^[\s\u00a0]+|[\s\u00a0]+$/gu, "").normalize("NFC"); + const hasControlCharacter = Array.from(display).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }); + if ( + display.length === 0 || + hasControlCharacter || + display.includes("\\b") || + /\s|\u00a0/u.test(display) || + display.includes("${") || + !/\p{L}/u.test(display) + ) { + return null; + } + + return { + normalizedWord: display.toLocaleLowerCase(resolveLocale(language)).normalize("NFC"), + display, + }; +} + +export function calculateEffectivePersonalizationScore( + word: Pick, + nowMs: number, +): number { + const elapsedMs = Math.max(0, nowMs - word.updatedAtMs); + return word.score * Math.exp(-elapsedMs / PERSONALIZATION_DECAY_WINDOW_MS); +} + +export function isPromotionEligible(score: number): boolean { + return score >= PERSONALIZATION_PROMOTION_THRESHOLD; +} + +export function prunePersonalizationLanguage( + words: Record, + nowMs: number, + limit = PERSONALIZATION_MAX_WORDS_PER_LANGUAGE, +): Record { + const entries = Object.entries(words); + if (entries.length <= limit) { + return { ...words }; + } + + entries.sort((left, right) => { + const scoreDelta = + calculateEffectivePersonalizationScore(right[1], nowMs) - + calculateEffectivePersonalizationScore(left[1], nowMs); + return scoreDelta !== 0 ? scoreDelta : right[1].updatedAtMs - left[1].updatedAtMs; + }); + return Object.fromEntries(entries.slice(0, Math.max(0, limit))); +} + +export function sanitizePersonalizationStore( + value: unknown, + nowMs: number, +): PersonalizationStoreV1 { + if (!isRecord(value) || value.version !== PERSONALIZATION_STORE_VERSION) { + return createEmptyPersonalizationStore(); + } + + const languages: PersonalizationStoreV1["languages"] = {}; + if (isRecord(value.languages)) { + for (const [language, rawWords] of Object.entries(value.languages)) { + if (!isPersonalizationLanguage(language) || !isRecord(rawWords)) { + continue; + } + const words: Record = {}; + for (const [rawKey, rawWord] of Object.entries(rawWords)) { + if (!isRecord(rawWord)) { + continue; + } + const normalized = normalizePersonalizationWord(rawKey, language); + const display = normalizePersonalizationWord(rawWord.display, language); + if ( + !normalized || + !display || + normalized.normalizedWord !== display.normalizedWord || + !isPositiveFiniteNumber(rawWord.score) || + !isValidTimestamp(rawWord.updatedAtMs) + ) { + continue; + } + words[normalized.normalizedWord] = { + display: display.display, + score: rawWord.score, + updatedAtMs: rawWord.updatedAtMs, + }; + } + const pruned = prunePersonalizationLanguage(words, nowMs); + if (Object.keys(pruned).length > 0) { + languages[language] = pruned; + } + } + } + + const recentEvents: Record = {}; + if (isRecord(value.recentEvents)) { + for (const [eventId, rawEvent] of Object.entries(value.recentEvents)) { + if (!isValidEventId(eventId) || !isRecord(rawEvent)) { + continue; + } + const language = rawEvent.language; + if (!isPersonalizationLanguage(language) || typeof rawEvent.applied !== "boolean") { + continue; + } + const normalized = normalizePersonalizationWord(rawEvent.normalizedWord, language); + if (!normalized || normalized.normalizedWord !== rawEvent.normalizedWord) { + continue; + } + recentEvents[eventId] = { + language, + normalizedWord: normalized.normalizedWord, + applied: rawEvent.applied, + }; + } + } + + return { + version: PERSONALIZATION_STORE_VERSION, + languages, + recentEvents: trimRecentEvents(recentEvents), + }; +} + +export function trimRecentEvents( + events: Record, +): Record { + const entries = Object.entries(events); + return Object.fromEntries(entries.slice(-PERSONALIZATION_MAX_RECENT_EVENTS)); +} + +export function isValidEventId(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 160; +} + +function resolveLocale(language: string): string { + return language.replace("_", "-"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function isValidTimestamp(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} diff --git a/src/core/domain/personalization/PersonalizationRanker.ts b/src/core/domain/personalization/PersonalizationRanker.ts new file mode 100644 index 00000000..1961cd14 --- /dev/null +++ b/src/core/domain/personalization/PersonalizationRanker.ts @@ -0,0 +1,48 @@ +import { + calculateEffectivePersonalizationScore, + isPromotionEligible, + normalizePersonalizationWord, +} from "./PersonalizationPolicy"; +import type { RankedCandidateOptions } from "./types"; + +export function rankPersonalizedCandidates(options: RankedCandidateOptions): string[] { + const candidates = options.candidates.slice(); + const languageSnapshot = options.snapshot[options.language]; + if (!languageSnapshot || candidates.length < 2) { + return candidates; + } + + const pinnedCandidates = options.pinnedCandidates ?? new Set(); + const ranked = candidates.map((candidate, index) => { + const normalized = normalizePersonalizationWord(candidate, options.language); + const learned = normalized ? languageSnapshot[normalized.normalizedWord] : undefined; + const effectiveScore = learned + ? calculateEffectivePersonalizationScore(learned, options.nowMs) + : 0; + return { + candidate, + index, + pinned: pinnedCandidates.has(candidate), + effectiveScore, + eligible: isPromotionEligible(effectiveScore), + }; + }); + + ranked.sort((left, right) => { + if (left.pinned !== right.pinned) { + return left.pinned ? -1 : 1; + } + if (left.pinned && right.pinned) { + return left.index - right.index; + } + if (left.eligible !== right.eligible) { + return left.eligible ? -1 : 1; + } + if (left.eligible && right.eligible && left.effectiveScore !== right.effectiveScore) { + return right.effectiveScore - left.effectiveScore; + } + return left.index - right.index; + }); + + return ranked.map(({ candidate }) => candidate); +} diff --git a/src/core/domain/personalization/types.ts b/src/core/domain/personalization/types.ts new file mode 100644 index 00000000..d050ac73 --- /dev/null +++ b/src/core/domain/personalization/types.ts @@ -0,0 +1,42 @@ +export interface PersonalizationWord { + display: string; + score: number; + updatedAtMs: number; +} + +export interface PersonalizationRecentEvent { + language: string; + normalizedWord: string; + applied: boolean; +} + +export interface PersonalizationStoreV1 { + version: 1; + languages: Record>; + recentEvents: Record; +} + +export type PersonalizationRankingSnapshot = Readonly< + Record>>> +>; + +export type PersonalizationEvent = + | { + eventType: "suggestion_accepted"; + eventId: string; + suggestion: string; + triggerText: string; + language: string; + } + | { + eventType: "suggestion_reverted"; + eventId: string; + }; + +export interface RankedCandidateOptions { + candidates: readonly string[]; + language: string; + snapshot: PersonalizationRankingSnapshot; + nowMs: number; + pinnedCandidates?: ReadonlySet; +} diff --git a/tests/PersonalizationPolicy.test.ts b/tests/PersonalizationPolicy.test.ts new file mode 100644 index 00000000..c17396d8 --- /dev/null +++ b/tests/PersonalizationPolicy.test.ts @@ -0,0 +1,82 @@ +import { + PERSONALIZATION_DECAY_WINDOW_MS, + PERSONALIZATION_MAX_WORDS_PER_LANGUAGE, + calculateEffectivePersonalizationScore, + normalizePersonalizationWord, + prunePersonalizationLanguage, + sanitizePersonalizationStore, +} from "../src/core/domain/personalization/PersonalizationPolicy"; + +describe("PersonalizationPolicy", () => { + test("normalizes Unicode, case, regular whitespace, and non-breaking spaces", () => { + expect(normalizePersonalizationWord(" \u00a0Café\u0301\u00a0 ", "fr_FR")).toEqual({ + normalizedWord: "café́".normalize("NFC"), + display: "Café́".normalize("NFC"), + }); + expect(normalizePersonalizationWord("ÄPFEL", "de_DE")?.normalizedWord).toBe("äpfel"); + }); + + test.each(["", "two words", "two\nwords", "\bword", "\\bword", "12345", "---", "${date}"])( + "rejects ineligible value %j", + (value) => { + expect(normalizePersonalizationWord(value, "en_US")).toBeNull(); + }, + ); + + test("decays scores deterministically", () => { + const score = calculateEffectivePersonalizationScore( + { score: 4, updatedAtMs: 1_000 }, + 1_000 + PERSONALIZATION_DECAY_WINDOW_MS, + ); + expect(score).toBeCloseTo(4 / Math.E, 10); + }); + + test("prunes lowest decayed scores first", () => { + const words = Object.fromEntries( + Array.from({ length: PERSONALIZATION_MAX_WORDS_PER_LANGUAGE + 1 }, (_, index) => [ + `word${String.fromCharCode(97 + (index % 26))}${index}`, + { + display: `word${index}`, + score: index === 0 ? 0.1 : 2, + updatedAtMs: 10_000 + index, + }, + ]), + ); + const pruned = prunePersonalizationLanguage(words, 20_000); + expect(Object.keys(pruned)).toHaveLength(PERSONALIZATION_MAX_WORDS_PER_LANGUAGE); + expect(pruned.worda0).toBeUndefined(); + }); + + test("repairs malformed stores while tolerating future fields", () => { + const repaired = sanitizePersonalizationStore( + { + version: 1, + future: true, + languages: { + en_US: { + valid: { display: "Valid", score: 2, updatedAtMs: 100, future: "ok" }, + mismatch: { display: "other", score: 2, updatedAtMs: 100 }, + invalidScore: { display: "invalidScore", score: -1, updatedAtMs: 100 }, + }, + unknown: { word: { display: "word", score: 2, updatedAtMs: 100 } }, + }, + recentEvents: { + accepted: { language: "en_US", normalizedWord: "valid", applied: true }, + bad: { language: "unknown", normalizedWord: "word", applied: true }, + }, + }, + 200, + ); + expect(repaired).toEqual({ + version: 1, + languages: { + en_US: { + valid: { display: "Valid", score: 2, updatedAtMs: 100 }, + }, + }, + recentEvents: { + accepted: { language: "en_US", normalizedWord: "valid", applied: true }, + }, + }); + }); +}); diff --git a/tests/PersonalizationRanker.test.ts b/tests/PersonalizationRanker.test.ts new file mode 100644 index 00000000..d9ed93fb --- /dev/null +++ b/tests/PersonalizationRanker.test.ts @@ -0,0 +1,74 @@ +import { rankPersonalizedCandidates } from "../src/core/domain/personalization/PersonalizationRanker"; + +describe("PersonalizationRanker", () => { + const snapshot = { + en_US: { + beta: { display: "Beta", score: 2, updatedAtMs: 1_000 }, + gamma: { display: "gamma", score: 3, updatedAtMs: 1_000 }, + tied: { display: "tied", score: 2, updatedAtMs: 1_000 }, + }, + de_DE: { + alpha: { display: "Alpha", score: 8, updatedAtMs: 1_000 }, + }, + }; + + test("promotes eligible candidates by score without mutating input", () => { + const candidates = ["alpha", "beta", "gamma", "delta"]; + expect( + rankPersonalizedCandidates({ + candidates, + language: "en_US", + snapshot, + nowMs: 1_000, + }), + ).toEqual(["gamma", "beta", "alpha", "delta"]); + expect(candidates).toEqual(["alpha", "beta", "gamma", "delta"]); + }); + + test("does not promote after only one acceptance", () => { + expect( + rankPersonalizedCandidates({ + candidates: ["alpha", "once", "delta"], + language: "en_US", + snapshot: { + en_US: { once: { display: "once", score: 1, updatedAtMs: 1_000 } }, + }, + nowMs: 1_000, + }), + ).toEqual(["alpha", "once", "delta"]); + }); + + test("preserves stable ties and unpersonalized order", () => { + expect( + rankPersonalizedCandidates({ + candidates: ["alpha", "tied", "beta", "delta"], + language: "en_US", + snapshot, + nowMs: 1_000, + }), + ).toEqual(["tied", "beta", "alpha", "delta"]); + }); + + test("keeps pinned exact matches and expansions ahead of personalization", () => { + expect( + rankPersonalizedCandidates({ + candidates: ["exact", "gamma", "expansion", "alpha"], + language: "en_US", + snapshot, + nowMs: 1_000, + pinnedCandidates: new Set(["exact", "expansion"]), + }), + ).toEqual(["exact", "expansion", "gamma", "alpha"]); + }); + + test("isolates learned ranking by language", () => { + expect( + rankPersonalizedCandidates({ + candidates: ["beta", "alpha"], + language: "de_DE", + snapshot, + nowMs: 1_000, + }), + ).toEqual(["alpha", "beta"]); + }); +}); From 50ade09b27a7bdbe5f2596e53c535eb018da026a Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 13:35:34 +0200 Subject: [PATCH 2/6] feat: persist personalized suggestion learning --- scripts/run-unit-tests.ts | 10 +- .../PersonalizationRepository.ts | 28 +++ .../personalization/PersonalizationService.ts | 224 ++++++++++++++++++ tests/PersonalizationRepository.test.ts | 54 +++++ tests/PersonalizationService.test.ts | 185 +++++++++++++++ 5 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 src/core/application/personalization/PersonalizationRepository.ts create mode 100644 src/core/application/personalization/PersonalizationService.ts create mode 100644 tests/PersonalizationRepository.test.ts create mode 100644 tests/PersonalizationService.test.ts diff --git a/scripts/run-unit-tests.ts b/scripts/run-unit-tests.ts index 44919a21..a5782183 100644 --- a/scripts/run-unit-tests.ts +++ b/scripts/run-unit-tests.ts @@ -4,8 +4,14 @@ const POPUP_TEST = "tests/popup.dashboard.retry.test.ts"; const SUGGESTION_MANAGER_TEST = "tests/SuggestionManager.test.ts"; const UTILS_TEST = "tests/utils.test.ts"; - -const ISOLATED_TESTS = new Set([POPUP_TEST, SUGGESTION_MANAGER_TEST, UTILS_TEST]); +const PERSONALIZATION_SERVICE_TEST = "tests/PersonalizationService.test.ts"; + +const ISOLATED_TESTS = new Set([ + POPUP_TEST, + SUGGESTION_MANAGER_TEST, + UTILS_TEST, + PERSONALIZATION_SERVICE_TEST, +]); function sortedUnique(entries: string[]): string[] { return [...new Set(entries)].sort((left, right) => left.localeCompare(right)); diff --git a/src/core/application/personalization/PersonalizationRepository.ts b/src/core/application/personalization/PersonalizationRepository.ts new file mode 100644 index 00000000..dcf40eab --- /dev/null +++ b/src/core/application/personalization/PersonalizationRepository.ts @@ -0,0 +1,28 @@ +import type { StorageBackend } from "../storage/StorageBackend"; +import type { PersonalizationStoreV1 } from "@core/domain/personalization/types"; + +export const PERSONALIZATION_STORAGE_KEY = "fluenttyper.personalization"; + +export class PersonalizationRepository { + constructor(private readonly storage: StorageBackend) {} + + async load(): Promise { + const serialized = await this.storage.get(PERSONALIZATION_STORAGE_KEY); + if (serialized === undefined) { + return undefined; + } + try { + return JSON.parse(serialized) as unknown; + } catch { + return undefined; + } + } + + async save(store: PersonalizationStoreV1): Promise { + await this.storage.set(PERSONALIZATION_STORAGE_KEY, JSON.stringify(store)); + } + + async clear(): Promise { + await this.storage.remove(PERSONALIZATION_STORAGE_KEY); + } +} diff --git a/src/core/application/personalization/PersonalizationService.ts b/src/core/application/personalization/PersonalizationService.ts new file mode 100644 index 00000000..3ae5a5b8 --- /dev/null +++ b/src/core/application/personalization/PersonalizationService.ts @@ -0,0 +1,224 @@ +import { + calculateEffectivePersonalizationScore, + createEmptyPersonalizationStore, + isValidEventId, + normalizePersonalizationWord, + prunePersonalizationLanguage, + sanitizePersonalizationStore, + trimRecentEvents, +} from "@core/domain/personalization/PersonalizationPolicy"; +import type { + PersonalizationEvent, + PersonalizationRankingSnapshot, + PersonalizationStoreV1, +} from "@core/domain/personalization/types"; +import type { PersonalizationRepository } from "./PersonalizationRepository"; + +export interface PersonalizationServiceOptions { + repository: PersonalizationRepository; + isEnabled: () => boolean | Promise; + isTextExpansionTrigger?: (triggerText: string) => boolean | Promise; + now?: () => number; +} + +export class PersonalizationService { + private readonly repository: PersonalizationRepository; + private readonly isEnabled: PersonalizationServiceOptions["isEnabled"]; + private readonly isTextExpansionTrigger: NonNullable< + PersonalizationServiceOptions["isTextExpansionTrigger"] + >; + private readonly now: NonNullable; + private store = createEmptyPersonalizationStore(); + private snapshot: PersonalizationRankingSnapshot = Object.freeze({}); + private initializationPromise: Promise | null = null; + private mutationQueue: Promise = Promise.resolve(); + + constructor(options: PersonalizationServiceOptions) { + this.repository = options.repository; + this.isEnabled = options.isEnabled; + this.isTextExpansionTrigger = options.isTextExpansionTrigger ?? (() => false); + this.now = options.now ?? Date.now; + } + + async initialize(): Promise { + if (!this.initializationPromise) { + this.initializationPromise = this.loadInitialStore(); + } + await this.initializationPromise; + } + + getRankingSnapshot(): PersonalizationRankingSnapshot { + return this.snapshot; + } + + async handleEvent(event: PersonalizationEvent): Promise { + if (event.eventType === "suggestion_accepted") { + return this.accept(event); + } + return this.revert(event.eventId); + } + + async accept(event: Extract) { + return this.serializeMutation(async () => { + if ( + !isValidEventId(event.eventId) || + !(await this.safeIsEnabled()) || + this.store.recentEvents[event.eventId] + ) { + return false; + } + + const normalized = normalizePersonalizationWord(event.suggestion, event.language); + if (!normalized || (await this.safeIsTextExpansionTrigger(event.triggerText))) { + return false; + } + + const nowMs = this.now(); + const next = cloneStore(this.store); + const languageWords = next.languages[event.language] ?? {}; + const current = languageWords[normalized.normalizedWord]; + languageWords[normalized.normalizedWord] = { + display: normalized.display, + score: (current ? calculateEffectivePersonalizationScore(current, nowMs) : 0) + 1, + updatedAtMs: nowMs, + }; + next.languages[event.language] = prunePersonalizationLanguage(languageWords, nowMs); + next.recentEvents[event.eventId] = { + language: event.language, + normalizedWord: normalized.normalizedWord, + applied: true, + }; + next.recentEvents = trimRecentEvents(next.recentEvents); + await this.commit(next); + return true; + }); + } + + async revert(eventId: string): Promise { + return this.serializeMutation(async () => { + if (!isValidEventId(eventId)) { + return false; + } + const recentEvent = this.store.recentEvents[eventId]; + if (!recentEvent?.applied) { + return false; + } + + const nowMs = this.now(); + const next = cloneStore(this.store); + const nextEvent = next.recentEvents[eventId]; + const languageWords = next.languages[nextEvent.language]; + const word = languageWords?.[nextEvent.normalizedWord]; + if (word) { + const reversedScore = calculateEffectivePersonalizationScore(word, nowMs) - 1; + if (reversedScore <= Number.EPSILON) { + delete languageWords[nextEvent.normalizedWord]; + } else { + languageWords[nextEvent.normalizedWord] = { + ...word, + score: reversedScore, + updatedAtMs: nowMs, + }; + } + if (Object.keys(languageWords).length === 0) { + delete next.languages[nextEvent.language]; + } + } + nextEvent.applied = false; + await this.commit(next); + return true; + }); + } + + async clear(): Promise { + await this.serializeMutation(async () => { + const empty = createEmptyPersonalizationStore(); + this.replaceInMemoryStore(empty); + await this.repository.clear(); + }); + } + + private async loadInitialStore(): Promise { + let raw: unknown; + try { + raw = await this.repository.load(); + } catch { + this.replaceInMemoryStore(createEmptyPersonalizationStore()); + return; + } + + const repaired = sanitizePersonalizationStore(raw, this.now()); + this.replaceInMemoryStore(repaired); + if (JSON.stringify(raw) !== JSON.stringify(repaired)) { + try { + await this.repository.save(repaired); + } catch { + // Ranking can safely continue from the repaired in-memory snapshot. + } + } + } + + private async commit(next: PersonalizationStoreV1): Promise { + this.replaceInMemoryStore(next); + await this.repository.save(next); + } + + private replaceInMemoryStore(store: PersonalizationStoreV1): void { + this.store = cloneStore(store); + this.snapshot = createImmutableSnapshot(this.store); + } + + private serializeMutation(mutation: () => Promise): Promise { + const result = this.mutationQueue.then(async () => { + await this.initialize(); + return mutation(); + }); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async safeIsEnabled(): Promise { + try { + return await this.isEnabled(); + } catch { + return false; + } + } + + private async safeIsTextExpansionTrigger(triggerText: string): Promise { + try { + return await this.isTextExpansionTrigger(triggerText); + } catch { + return true; + } + } +} + +function cloneStore(store: PersonalizationStoreV1): PersonalizationStoreV1 { + return { + version: 1, + languages: Object.fromEntries( + Object.entries(store.languages).map(([language, words]) => [ + language, + Object.fromEntries(Object.entries(words).map(([key, word]) => [key, { ...word }])), + ]), + ), + recentEvents: Object.fromEntries( + Object.entries(store.recentEvents).map(([eventId, event]) => [eventId, { ...event }]), + ), + }; +} + +function createImmutableSnapshot(store: PersonalizationStoreV1): PersonalizationRankingSnapshot { + const languages: Record>>> = {}; + for (const [language, words] of Object.entries(store.languages)) { + const immutableWords = Object.fromEntries( + Object.entries(words).map(([key, word]) => [key, Object.freeze({ ...word })]), + ); + languages[language] = Object.freeze(immutableWords); + } + return Object.freeze(languages) as PersonalizationRankingSnapshot; +} diff --git a/tests/PersonalizationRepository.test.ts b/tests/PersonalizationRepository.test.ts new file mode 100644 index 00000000..b59e2c37 --- /dev/null +++ b/tests/PersonalizationRepository.test.ts @@ -0,0 +1,54 @@ +import { PersonalizationRepository } from "../src/core/application/personalization/PersonalizationRepository"; +import type { StorageBackend } from "../src/core/application/storage/StorageBackend"; + +class MemoryStorageBackend implements StorageBackend { + values = new Map(); + + async get(key: string) { + return this.values.get(key); + } + + async set(key: string, value: string) { + this.values.set(key, value); + } + + async remove(key: string) { + this.values.delete(key); + } + + async getAll(prefix: string) { + return Object.fromEntries( + [...this.values.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => [key.slice(prefix.length), value]), + ); + } +} + +describe("PersonalizationRepository", () => { + test("loads, saves, and clears the dedicated record", async () => { + const backend = new MemoryStorageBackend(); + const repository = new PersonalizationRepository(backend); + const store = { + version: 1 as const, + languages: { + en_US: { + hello: { display: "Hello", score: 2, updatedAtMs: 100 }, + }, + }, + recentEvents: {}, + }; + + await repository.save(store); + await expect(repository.load()).resolves.toEqual(store); + await repository.clear(); + await expect(repository.load()).resolves.toBeUndefined(); + }); + + test("treats unreadable data as empty", async () => { + const backend = new MemoryStorageBackend(); + backend.values.set("fluenttyper.personalization", "{invalid"); + const repository = new PersonalizationRepository(backend); + await expect(repository.load()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/PersonalizationService.test.ts b/tests/PersonalizationService.test.ts new file mode 100644 index 00000000..a847b5dc --- /dev/null +++ b/tests/PersonalizationService.test.ts @@ -0,0 +1,185 @@ +import { jest } from "bun:test"; +import { PersonalizationRepository } from "../src/core/application/personalization/PersonalizationRepository"; +import { PersonalizationService } from "../src/core/application/personalization/PersonalizationService"; +import type { StorageBackend } from "../src/core/application/storage/StorageBackend"; + +class CountingMemoryStorageBackend implements StorageBackend { + values = new Map(); + reads = 0; + writes = 0; + removes = 0; + + async get(key: string) { + this.reads += 1; + return this.values.get(key); + } + + async set(key: string, value: string) { + this.writes += 1; + this.values.set(key, value); + } + + async remove(key: string) { + this.removes += 1; + this.values.delete(key); + } + + async getAll(prefix: string) { + this.reads += 1; + return Object.fromEntries( + [...this.values.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => [key.slice(prefix.length), value]), + ); + } +} + +function accepted(eventId: string, suggestion = "hello", language = "en_US") { + return { + eventType: "suggestion_accepted" as const, + eventId, + suggestion, + triggerText: "hel", + language, + }; +} + +describe("PersonalizationService", () => { + test("loads, mutates, persists, restarts, and serves storage-free snapshots", async () => { + const backend = new CountingMemoryStorageBackend(); + const repository = new PersonalizationRepository(backend); + const service = new PersonalizationService({ + repository, + isEnabled: () => true, + now: () => 1_000, + }); + + await service.initialize(); + expect(await service.handleEvent(accepted("one"))).toBe(true); + expect(await service.handleEvent(accepted("two"))).toBe(true); + expect(service.getRankingSnapshot().en_US.hello.score).toBe(2); + + const readsBeforeSnapshot = backend.reads; + service.getRankingSnapshot(); + service.getRankingSnapshot(); + expect(backend.reads).toBe(readsBeforeSnapshot); + + const restarted = new PersonalizationService({ + repository, + isEnabled: () => true, + now: () => 1_000, + }); + await restarted.initialize(); + expect(restarted.getRankingSnapshot().en_US.hello.score).toBe(2); + }); + + test("serializes concurrent acceptances without losing updates", async () => { + const backend = new CountingMemoryStorageBackend(); + const service = new PersonalizationService({ + repository: new PersonalizationRepository(backend), + isEnabled: () => true, + now: () => 1_000, + }); + + await Promise.all( + Array.from({ length: 20 }, (_, index) => service.accept(accepted(`event-${index}`))), + ); + expect(service.getRankingSnapshot().en_US.hello.score).toBe(20); + }); + + test("makes duplicate acceptance and reversal idempotent", async () => { + const service = new PersonalizationService({ + repository: new PersonalizationRepository(new CountingMemoryStorageBackend()), + isEnabled: () => true, + now: () => 1_000, + }); + + expect(await service.accept(accepted("same"))).toBe(true); + expect(await service.accept(accepted("same"))).toBe(false); + expect(await service.revert("same")).toBe(true); + expect(await service.revert("same")).toBe(false); + expect(await service.revert("unknown")).toBe(false); + expect(service.getRankingSnapshot()).toEqual({}); + }); + + test("keeps newer acceptance evidence when reverting one event", async () => { + let nowMs = 1_000; + const service = new PersonalizationService({ + repository: new PersonalizationRepository(new CountingMemoryStorageBackend()), + isEnabled: () => true, + now: () => nowMs, + }); + await service.accept(accepted("first")); + await service.accept(accepted("second")); + nowMs += 10; + + expect(await service.revert("first")).toBe(true); + expect(service.getRankingSnapshot().en_US.hello.score).toBeCloseTo(1, 5); + }); + + test("disabled mode and text expansions do not learn or persist events", async () => { + const backend = new CountingMemoryStorageBackend(); + const isEnabled = jest.fn(() => false); + const service = new PersonalizationService({ + repository: new PersonalizationRepository(backend), + isEnabled, + isTextExpansionTrigger: (trigger) => trigger.toLowerCase() === "asap", + now: () => 1_000, + }); + await service.initialize(); + const writesAfterRepair = backend.writes; + + expect(await service.accept(accepted("disabled"))).toBe(false); + isEnabled.mockReturnValue(true); + expect( + await service.accept({ + ...accepted("expansion", "output"), + triggerText: "ASAP", + }), + ).toBe(false); + expect(service.getRankingSnapshot()).toEqual({}); + expect(backend.writes).toBe(writesAfterRepair); + }); + + test("clears persisted data and in-memory ranking immediately", async () => { + const backend = new CountingMemoryStorageBackend(); + const service = new PersonalizationService({ + repository: new PersonalizationRepository(backend), + isEnabled: () => true, + now: () => 1_000, + }); + await service.accept(accepted("one")); + + await service.clear(); + expect(service.getRankingSnapshot()).toEqual({}); + expect(backend.removes).toBe(1); + }); + + test("repairs malformed persisted data without breaking initialization", async () => { + const backend = new CountingMemoryStorageBackend(); + backend.values.set( + "fluenttyper.personalization", + JSON.stringify({ + version: 1, + languages: { + en_US: { + valid: { display: "Valid", score: 2, updatedAtMs: 100 }, + bad: { display: "Bad", score: "no", updatedAtMs: 100 }, + }, + }, + recentEvents: {}, + }), + ); + const service = new PersonalizationService({ + repository: new PersonalizationRepository(backend), + isEnabled: () => true, + now: () => 1_000, + }); + + await expect(service.initialize()).resolves.toBeUndefined(); + expect(service.getRankingSnapshot()).toEqual({ + en_US: { valid: { display: "Valid", score: 2, updatedAtMs: 100 } }, + }); + expect(backend.writes).toBe(1); + }); +}); From 8e6a9ed32833e1d593377031517c7da87c020196 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 13:37:56 +0200 Subject: [PATCH 3/6] feat: learn and revert accepted suggestions --- .../background/BackgroundServiceWorker.ts | 34 +++++++- .../chrome/background/router/MessageRouter.ts | 28 +++++++ .../suggestions/SuggestionEntrySession.ts | 12 +++ .../suggestions/SuggestionManagerRuntime.ts | 19 +++++ .../SuggestionPersonalizationService.ts | 80 +++++++++++++++++++ .../suggestions/SuggestionTextEditService.ts | 17 +++- .../content-script/suggestions/types.ts | 16 ++++ .../repositories/CoreSettingsRepository.ts | 4 + src/core/domain/constants.ts | 3 + src/core/domain/contracts/messages.ts | 4 + src/core/domain/contracts/settings.ts | 3 + src/core/domain/messageTypes.d.ts | 19 +++++ tests/SuggestionEntrySession.test.ts | 10 +++ .../SuggestionPersonalizationService.test.ts | 58 ++++++++++++++ tests/SuggestionTextEditService.test.ts | 11 +++ tests/background.routing.test.ts | 66 +++++++++++++++ 16 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionPersonalizationService.ts create mode 100644 tests/SuggestionPersonalizationService.test.ts diff --git a/src/adapters/chrome/background/BackgroundServiceWorker.ts b/src/adapters/chrome/background/BackgroundServiceWorker.ts index c6e751c8..178abefc 100644 --- a/src/adapters/chrome/background/BackgroundServiceWorker.ts +++ b/src/adapters/chrome/background/BackgroundServiceWorker.ts @@ -32,6 +32,10 @@ import { } from "./config/runtimeSettings"; import { ConfigAssembler } from "./config/ConfigAssembler"; import { ObservabilityService } from "./ObservabilityService"; +import { ChromeStorageBackend } from "@core/application/storage/ChromeStorageBackend"; +import { PersonalizationRepository } from "@core/application/personalization/PersonalizationRepository"; +import { PersonalizationService } from "@core/application/personalization/PersonalizationService"; +import type { PersonalizationEvent } from "@core/domain/personalization/types"; declare const __FT_DEV_BUILD__: boolean | undefined; @@ -49,6 +53,7 @@ export class BackgroundServiceWorker { productivityStatsManager!: ProductivityStatsManager; observabilityService!: ObservabilityService; configAssembler!: ConfigAssembler; + personalizationService!: PersonalizationService; language!: string; private runtimeConfigReady = false; private runtimeConfigLoadPromise: Promise | null = null; @@ -60,6 +65,17 @@ export class BackgroundServiceWorker { } this.settingsManager = new SettingsManager(); this.coreSettingsRepository = new CoreSettingsRepository(this.settingsManager); + this.personalizationService = new PersonalizationService({ + repository: new PersonalizationRepository(new ChromeStorageBackend(true)), + isEnabled: () => this.coreSettingsRepository.getPersonalizationEnabled(), + isTextExpansionTrigger: async (triggerText) => { + const normalizedTrigger = triggerText.trim().toLocaleLowerCase(); + const expansions = await this.coreSettingsRepository.getTextExpansions(); + return expansions.some( + ([shortcut]) => shortcut.trim().toLocaleLowerCase() === normalizedTrigger, + ); + }, + }); this.languageDetector = new LanguageDetector(this.settingsManager); this.predictionManager = new PredictionManager(); this.tabMessenger = new TabMessenger(); @@ -216,7 +232,10 @@ export class BackgroundServiceWorker { async updatePresageConfig(): Promise { await sanitizeSiteProfilesSetting(this.settingsManager); await sanitizeAutoLanguagePriorsSetting(this.settingsManager); - await this.predictionManager.initialize(); + await Promise.all([ + this.personalizationService.initialize(), + this.predictionManager.initialize(), + ]); const runtimeConfig = await this.configAssembler.assemblePredictionRuntimeConfig(); this.language = runtimeConfig.language; this.observabilityService.setConfig(runtimeConfig.observabilityConfig); @@ -304,7 +323,10 @@ export class BackgroundServiceWorker { await migrateSettingsV5(this.settingsManager); await migrateSettingsV6(this.settingsManager); await migrateSettingsV7(this.settingsManager); - await this.predictionManager.initialize(); + await Promise.all([ + this.personalizationService.initialize(), + this.predictionManager.initialize(), + ]); await this.updatePresageConfig(); } catch (error) { logError("lastVersion handler", error); @@ -313,6 +335,14 @@ export class BackgroundServiceWorker { await this.initializationPromise; } + async handlePersonalizationEvent(event: PersonalizationEvent): Promise { + return this.personalizationService.handleEvent(event); + } + + async clearPersonalization(): Promise { + await this.personalizationService.clear(); + } + private async ensureRuntimeConfigReady(): Promise { if (this.runtimeConfigReady) { return; diff --git a/src/adapters/chrome/background/router/MessageRouter.ts b/src/adapters/chrome/background/router/MessageRouter.ts index 8e162419..0930d5eb 100644 --- a/src/adapters/chrome/background/router/MessageRouter.ts +++ b/src/adapters/chrome/background/router/MessageRouter.ts @@ -7,6 +7,7 @@ import { CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_MODULES, CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_CONTENT_SCRIPT_USAGE_EVENT, + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, CMD_GET_AUTO_LANGUAGE_STATUS, CMD_OPTIONS_CLEAR_OBSERVABILITY_EVENTS, CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE, @@ -16,6 +17,7 @@ import { CMD_OPTIONS_REPORT_OBSERVABILITY_MODULES, CMD_OPTIONS_PAGE_CONFIG_CHANGE, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_POPUP_ACK_DONATION_MILESTONE, CMD_POPUP_ACK_WEEKLY_RECAP, CMD_POPUP_GET_PRODUCTIVITY_STATS, @@ -53,6 +55,7 @@ const ROUTED_MESSAGE_COMMANDS = [ CMD_OPTIONS_PAGE_CONFIG_CHANGE, CMD_CONTENT_SCRIPT_GET_CONFIG, CMD_CONTENT_SCRIPT_USAGE_EVENT, + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_EVENT, CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_MODULES, @@ -61,6 +64,7 @@ const ROUTED_MESSAGE_COMMANDS = [ CMD_POPUP_ACK_WEEKLY_RECAP, CMD_POPUP_ACK_DONATION_MILESTONE, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT, CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE, CMD_OPTIONS_GET_OBSERVABILITY_SNAPSHOT, @@ -122,6 +126,8 @@ const MESSAGE_ERROR_LABELS: Record = { [CMD_OPTIONS_PAGE_CONFIG_CHANGE]: "handleOptionsPageConfigChange", [CMD_CONTENT_SCRIPT_GET_CONFIG]: "MessageRouter.handleContentScriptGetConfig", [CMD_CONTENT_SCRIPT_USAGE_EVENT]: "MessageRouter.handleContentScriptUsageEvent", + [CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT]: + "MessageRouter.handleContentScriptPersonalizationEvent", [CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS]: "MessageRouter.handleContentScriptRuntimeStatus", [CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_EVENT]: "MessageRouter.handleContentScriptReportObservabilityEvent", @@ -132,6 +138,7 @@ const MESSAGE_ERROR_LABELS: Record = { [CMD_POPUP_ACK_WEEKLY_RECAP]: "MessageRouter.handlePopupAckWeeklyRecap", [CMD_POPUP_ACK_DONATION_MILESTONE]: "MessageRouter.handlePopupAckDonationMilestone", [CMD_OPTIONS_RESET_PRODUCTIVITY_STATS]: "MessageRouter.handleOptionsResetProductivityStats", + [CMD_OPTIONS_CLEAR_PERSONALIZATION]: "MessageRouter.handleOptionsClearPersonalization", [CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT]: "MessageRouter.handleOptionsGetPredictorDebugSnapshot", [CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE]: "MessageRouter.handleOptionsClearPredictorDebugTrace", @@ -180,6 +187,10 @@ export class MessageRouter { register(CMD_OPTIONS_PAGE_CONFIG_CHANGE, this.handleOptionsPageConfigChange.bind(this)); register(CMD_CONTENT_SCRIPT_GET_CONFIG, this.handleContentScriptGetConfig.bind(this)); register(CMD_CONTENT_SCRIPT_USAGE_EVENT, this.handleContentScriptUsageEvent.bind(this)); + register( + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, + this.handleContentScriptPersonalizationEvent.bind(this), + ); register( CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, this.handleContentScriptRuntimeStatus.bind(this), @@ -200,6 +211,7 @@ export class MessageRouter { CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, this.handleOptionsResetProductivityStats.bind(this), ); + register(CMD_OPTIONS_CLEAR_PERSONALIZATION, this.handleOptionsClearPersonalization.bind(this)); register( CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT, this.handleOptionsGetPredictorDebugSnapshot.bind(this), @@ -441,6 +453,14 @@ export class MessageRouter { this.respondOk(sendResponse); } + private async handleContentScriptPersonalizationEvent( + payload: CommandPayload, + ): Promise { + const { request, sendResponse, worker } = payload; + await worker.handlePersonalizationEvent(request.context); + this.respondOk(sendResponse); + } + private handleContentScriptRuntimeStatus( payload: CommandPayload, ): void { @@ -555,6 +575,14 @@ export class MessageRouter { this.respondOk(sendResponse); } + private async handleOptionsClearPersonalization( + payload: CommandPayload, + ): Promise { + const { sendResponse, worker } = payload; + await worker.clearPersonalization(); + this.respondOk(sendResponse); + } + private async handleOptionsGetPredictorDebugSnapshot( payload: CommandPayload, ): Promise { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts index c097ca9e..20988919 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts @@ -60,6 +60,9 @@ export class SuggestionEntrySession { private readonly renderInline: () => void; private readonly recordSuggestionShown: SuggestionEntrySessionOptions["recordSuggestionShown"]; private readonly recordSuggestionAccepted: SuggestionEntrySessionOptions["recordSuggestionAccepted"]; + private readonly recordPersonalizationAccepted: NonNullable< + SuggestionEntrySessionOptions["recordPersonalizationAccepted"] + >; private readonly getLang: () => string; private readonly insertSpaceAfterAutocomplete: boolean; private readonly logRenderedSuggestionPopup: SuggestionEntrySessionOptions["logRenderedSuggestionPopup"]; @@ -84,6 +87,7 @@ export class SuggestionEntrySession { this.renderInline = options.renderInline; this.recordSuggestionShown = options.recordSuggestionShown; this.recordSuggestionAccepted = options.recordSuggestionAccepted; + this.recordPersonalizationAccepted = options.recordPersonalizationAccepted ?? (() => ""); this.getLang = options.getLang; this.insertSpaceAfterAutocomplete = options.insertSpaceAfterAutocomplete; this.logRenderedSuggestionPopup = options.logRenderedSuggestionPopup; @@ -1242,6 +1246,14 @@ export class SuggestionEntrySession { return false; } this.lastAcceptedSuggestion = suggestion; + const personalizationEventId = this.recordPersonalizationAccepted({ + suggestion, + triggerText: accepted.triggerText, + language: this.getLang(), + }); + if (personalizationEventId && this.entry.pendingExtensionEdit?.source === "suggestion") { + this.entry.pendingExtensionEdit.personalizationEventId = personalizationEventId; + } this.finishAcceptedSuggestion( accepted.triggerText, accepted.insertedText, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index e4edcf91..875ccc9a 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -20,6 +20,7 @@ import { SuggestionPredictionCoordinator } from "./SuggestionPredictionCoordinat import { resolveSuggestionStateHost } from "./SuggestionStateHost"; import { SuggestionMenuView } from "./SuggestionMenuView"; import { SuggestionTelemetryService } from "./SuggestionTelemetryService"; +import { SuggestionPersonalizationService } from "./SuggestionPersonalizationService"; import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; import { EditableContextResolver } from "./EditableContextResolver"; import { SuggestionTextEditService } from "./SuggestionTextEditService"; @@ -38,6 +39,7 @@ import type { SuggestionElement, SuggestionEntry, SuggestionManagerOptions, + SuggestionPersonalization, SuggestionTelemetry, } from "./types"; @@ -82,6 +84,7 @@ export class SuggestionManagerRuntime { private readonly textEditService: SuggestionTextEditService; private readonly keyboardHandler: SuggestionKeyboardHandler; private readonly telemetry: SuggestionTelemetry; + private readonly personalization: SuggestionPersonalization; private readonly pendingKeyFallbacks = new Map(); private readonly displayLangHeader: boolean; @@ -136,6 +139,7 @@ export class SuggestionManagerRuntime { separatorRegex: this.separatorRegex, }); this.telemetry = options.telemetry ?? new SuggestionTelemetryService(); + this.personalization = options.personalization ?? new SuggestionPersonalizationService(); this.textEditService = new SuggestionTextEditService({ findMentionToken: this.predictionCoordinator.findMentionToken.bind( this.predictionCoordinator, @@ -159,6 +163,7 @@ export class SuggestionManagerRuntime { this.textEditService.tryUndoLastExtensionEdit(entry, event, { consumeKeyboardEvent: this.consumeCancelableEvent.bind(this), clearSuggestions: () => this.clearSuggestions(entry), + onSuccessfulUndo: (edit) => this.recordPersonalizationReversal(edit), }), consumeKeyboardEvent: this.consumeCancelableEvent.bind(this), clearSuggestions: this.clearSuggestions.bind(this), @@ -681,6 +686,7 @@ export class SuggestionManagerRuntime { const handled = this.textEditService.tryUndoLastExtensionEditOnBeforeInput(entry, inputEvent, { consumeInputEvent: this.consumeCancelableEvent.bind(this), clearSuggestions: () => this.clearSuggestions(entry), + onSuccessfulUndo: (edit) => this.recordPersonalizationReversal(edit), }); if (handled) { this.clearPendingKeyFallback(id); @@ -763,6 +769,8 @@ export class SuggestionManagerRuntime { }), recordSuggestionShown: (context) => this.telemetry.recordSuggestionShown(context), recordSuggestionAccepted: (context) => this.telemetry.recordSuggestionAccepted(context), + recordPersonalizationAccepted: (context) => + this.personalization.recordSuggestionAccepted(context), getLang: () => this.lang, insertSpaceAfterAutocomplete: this.insertSpaceAfterAutocomplete, logRenderedSuggestionPopup: (context, details) => { @@ -788,6 +796,17 @@ export class SuggestionManagerRuntime { }); } + private recordPersonalizationReversal( + edit: Pick< + NonNullable, + "source" | "personalizationEventId" + >, + ): void { + if (edit.source === "suggestion" && edit.personalizationEventId) { + this.personalization.recordSuggestionReverted(edit.personalizationEventId); + } + } + private reconcileEntrySelection(entry: SuggestionEntry): void { this.getSession(entry.id)?.reconcileSelection({ dismissEntry: () => this.dismissEntry(entry, true), diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionPersonalizationService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionPersonalizationService.ts new file mode 100644 index 00000000..8e901c6a --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionPersonalizationService.ts @@ -0,0 +1,80 @@ +import { CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT } from "@core/domain/constants"; +import type { ContentScriptPersonalizationEventMessage } from "@core/domain/messageTypes"; +import type { SuggestionPersonalization } from "./types"; + +interface SuggestionPersonalizationServiceOptions { + sendMessage?: (message: ContentScriptPersonalizationEventMessage, callback: () => void) => void; + readLastError?: () => unknown; + createEventId?: () => string; +} + +export class SuggestionPersonalizationService implements SuggestionPersonalization { + private readonly sendMessage: NonNullable; + private readonly readLastError: NonNullable< + SuggestionPersonalizationServiceOptions["readLastError"] + >; + private readonly createEventId: NonNullable< + SuggestionPersonalizationServiceOptions["createEventId"] + >; + + constructor(options: SuggestionPersonalizationServiceOptions = {}) { + this.sendMessage = + options.sendMessage ?? + ((message, callback) => { + chrome.runtime.sendMessage(message, callback); + }); + this.readLastError = options.readLastError ?? (() => chrome.runtime.lastError); + this.createEventId = options.createEventId ?? generateEventId; + } + + recordSuggestionAccepted(args: { + suggestion: string; + triggerText: string; + language: string; + }): string { + const eventId = this.createEventId(); + this.emit({ + command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, + context: { + eventType: "suggestion_accepted", + eventId, + suggestion: args.suggestion, + triggerText: args.triggerText, + language: args.language, + }, + }); + return eventId; + } + + recordSuggestionReverted(eventId: string): void { + this.emit({ + command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, + context: { + eventType: "suggestion_reverted", + eventId, + }, + }); + } + + private emit(message: ContentScriptPersonalizationEventMessage): void { + try { + this.sendMessage(message, () => { + try { + void this.readLastError(); + } catch { + // Ignore runtime teardown. + } + }); + } catch { + // A suspended or reloading background must never break suggestion acceptance. + } + } +} + +function generateEventId(): string { + const value = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `accept-${value}`; +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index edd9ab61..05591cc4 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -7,6 +7,7 @@ import { CURSOR_MOVE_COUNT_ATTR, CURSOR_MOVE_EVENT } from "./HostEditorBridgePro import { TextTargetAdapter, type TextTarget } from "./TextTargetAdapter"; import { buildCaretTrace, clipTraceText, collapseTraceWhitespace } from "./traceUtils"; import type { + ExtensionEditSnapshot, ManualAutoFixSuppressionSnapshot, SuggestionEntry, SuggestionElement, @@ -257,14 +258,17 @@ export class SuggestionTextEditService { { consumeKeyboardEvent, clearSuggestions, + onSuccessfulUndo, }: { consumeKeyboardEvent: (event: KeyboardEvent) => void; clearSuggestions: () => void; + onSuccessfulUndo?: (edit: ExtensionEditSnapshot) => void; }, ): boolean { return this.tryUndoPendingExtensionEdit(entry, event, { consumeEvent: (undoEvent) => consumeKeyboardEvent(undoEvent as KeyboardEvent), clearSuggestions, + onSuccessfulUndo, }); } @@ -274,9 +278,11 @@ export class SuggestionTextEditService { { consumeInputEvent, clearSuggestions, + onSuccessfulUndo, }: { consumeInputEvent: (event: InputEvent) => void; clearSuggestions: () => void; + onSuccessfulUndo?: (edit: ExtensionEditSnapshot) => void; }, ): boolean { if (event.inputType !== "historyUndo") { @@ -285,6 +291,7 @@ export class SuggestionTextEditService { return this.tryUndoPendingExtensionEdit(entry, event, { consumeEvent: (undoEvent) => consumeInputEvent(undoEvent as InputEvent), clearSuggestions, + onSuccessfulUndo, }); } @@ -294,9 +301,11 @@ export class SuggestionTextEditService { { consumeEvent, clearSuggestions, + onSuccessfulUndo, }: { consumeEvent: (event: Event) => void; clearSuggestions: () => void; + onSuccessfulUndo?: (edit: ExtensionEditSnapshot) => void; }, ): boolean { if (!entry.pendingExtensionEdit) { @@ -311,10 +320,12 @@ export class SuggestionTextEditService { return this.tryUndoBlockScopedExtensionEdit(entry, event, { consumeEvent, clearSuggestions, + onSuccessfulUndo, }); } const snapshot: SuggestionSnapshot = TextTargetAdapter.snapshot(entry.elem as TextTarget); + const pendingEdit = entry.pendingExtensionEdit; const { replaceStart, originalText, @@ -323,7 +334,7 @@ export class SuggestionTextEditService { postEditFingerprint, source, sourceRuleId, - } = entry.pendingExtensionEdit; + } = pendingEdit; const fullText = `${snapshot.beforeCursor}${snapshot.afterCursor}`; const replaceEnd = replaceStart + replacementText.length; @@ -389,6 +400,7 @@ export class SuggestionTextEditService { ); clearSuggestions(); + onSuccessfulUndo?.(pendingEdit); return true; } @@ -398,9 +410,11 @@ export class SuggestionTextEditService { { consumeEvent, clearSuggestions, + onSuccessfulUndo, }: { consumeEvent: (event: Event) => void; clearSuggestions: () => void; + onSuccessfulUndo?: (edit: ExtensionEditSnapshot) => void; }, ): boolean { const pendingEdit = entry.pendingExtensionEdit; @@ -455,6 +469,7 @@ export class SuggestionTextEditService { ); clearSuggestions(); + onSuccessfulUndo?.(pendingEdit); return true; } diff --git a/src/adapters/chrome/content-script/suggestions/types.ts b/src/adapters/chrome/content-script/suggestions/types.ts index a9e4f865..828741cb 100644 --- a/src/adapters/chrome/content-script/suggestions/types.ts +++ b/src/adapters/chrome/content-script/suggestions/types.ts @@ -68,6 +68,7 @@ export interface SuggestionManagerOptions { userDictionaryList: string[]; getPrediction: (context: PredictionRequest) => void; telemetry?: SuggestionTelemetry; + personalization?: SuggestionPersonalization; onShadowRootDiscovered?: (root: ShadowRoot) => void; } @@ -80,6 +81,15 @@ export interface SuggestionTelemetry { }): void; } +export interface SuggestionPersonalization { + recordSuggestionAccepted(args: { + suggestion: string; + triggerText: string; + language: string; + }): string; + recordSuggestionReverted(eventId: string): void; +} + export interface ExtensionEditSnapshot { replaceStart: number; originalText: string; @@ -90,6 +100,7 @@ export interface ExtensionEditSnapshot { awaitingHostInputEcho?: boolean; source: "suggestion" | "grammar"; sourceRuleId?: string; + personalizationEventId?: string; blockScoped?: boolean; postEditBlockText?: string | null; blockElement?: HTMLElement | null; @@ -193,6 +204,11 @@ export interface SuggestionEntrySessionOptions { insertedText: string; language: string; }) => void; + recordPersonalizationAccepted?: (context: { + suggestion: string; + triggerText: string; + language: string; + }) => string; getLang: () => string; insertSpaceAfterAutocomplete: boolean; logRenderedSuggestionPopup: ( diff --git a/src/core/application/repositories/CoreSettingsRepository.ts b/src/core/application/repositories/CoreSettingsRepository.ts index c3cd4be3..54df5482 100644 --- a/src/core/application/repositories/CoreSettingsRepository.ts +++ b/src/core/application/repositories/CoreSettingsRepository.ts @@ -104,6 +104,10 @@ export class CoreSettingsRepository extends SettingsRepositoryBase { return this.getBooleanField("prefixOnlyMode"); } + async getPersonalizationEnabled(): Promise { + return this.getBooleanField("personalizationEnabled"); + } + async getPreferNativeAutocomplete(): Promise { return this.getBooleanField("preferNativeAutocomplete", true); } diff --git a/src/core/domain/constants.ts b/src/core/domain/constants.ts index 7c3875a5..048e01cd 100644 --- a/src/core/domain/constants.ts +++ b/src/core/domain/constants.ts @@ -14,11 +14,13 @@ export const CMD_TRIGGER_FT_ACTIVE_TAB = "CMD_TRIGGER_FT_ACTIVE_TAB"; export const CMD_TOGGLE_FT_ACTIVE_LANG = "CMD_TOGGLE_FT_ACTIVE_LANG"; export const CMD_GET_HOSTNAME = "CMD_GET_HOSTNAME"; export const CMD_CONTENT_SCRIPT_USAGE_EVENT = "CMD_CONTENT_SCRIPT_USAGE_EVENT"; +export const CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT = "CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT"; export const CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS = "CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS"; export const CMD_POPUP_GET_PRODUCTIVITY_STATS = "CMD_POPUP_GET_PRODUCTIVITY_STATS"; export const CMD_POPUP_ACK_WEEKLY_RECAP = "CMD_POPUP_ACK_WEEKLY_RECAP"; export const CMD_POPUP_ACK_DONATION_MILESTONE = "CMD_POPUP_ACK_DONATION_MILESTONE"; export const CMD_OPTIONS_RESET_PRODUCTIVITY_STATS = "CMD_OPTIONS_RESET_PRODUCTIVITY_STATS"; +export const CMD_OPTIONS_CLEAR_PERSONALIZATION = "CMD_OPTIONS_CLEAR_PERSONALIZATION"; export const CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT = "CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT"; export const CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE = "CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE"; export const CMD_OPTIONS_GET_OBSERVABILITY_SNAPSHOT = "CMD_OPTIONS_GET_OBSERVABILITY_SNAPSHOT"; @@ -72,6 +74,7 @@ export const KEY_OBSERVABILITY_MODULE_OVERRIDES = "observabilityModuleOverrides" export const KEY_DISPLAY_LANG_HEADER = "displayLangHeader"; export const KEY_INLINE_SUGGESTION = "inline_suggestion"; export const KEY_PREFIX_ONLY_MODE = "prefixOnlyMode"; +export const KEY_PERSONALIZATION_ENABLED = "personalizationEnabled"; export const KEY_PREFER_NATIVE_AUTOCOMPLETE = "preferNativeAutocomplete"; export const KEY_EXTENSION_LANGUAGE = "extensionLanguage"; export const KEY_ENABLED = "enable"; diff --git a/src/core/domain/contracts/messages.ts b/src/core/domain/contracts/messages.ts index b1f92aa5..a9582b9f 100644 --- a/src/core/domain/contracts/messages.ts +++ b/src/core/domain/contracts/messages.ts @@ -9,6 +9,7 @@ import { CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_MODULES, CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_CONTENT_SCRIPT_USAGE_EVENT, + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, CMD_GET_AUTO_LANGUAGE_STATUS, CMD_GET_HOSTNAME, CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE, @@ -19,6 +20,7 @@ import { CMD_OPTIONS_REPORT_OBSERVABILITY_MODULES, CMD_OPTIONS_PAGE_CONFIG_CHANGE, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_POPUP_ACK_DONATION_MILESTONE, CMD_POPUP_ACK_WEEKLY_RECAP, CMD_POPUP_GET_PRODUCTIVITY_STATS, @@ -41,11 +43,13 @@ export const MESSAGE_COMMANDS = [ CMD_CONTENT_SCRIPT_PREDICT_REQ, CMD_CONTENT_SCRIPT_GET_CONFIG, CMD_CONTENT_SCRIPT_USAGE_EVENT, + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_EVENT, CMD_CONTENT_SCRIPT_REPORT_OBSERVABILITY_MODULES, CMD_OPTIONS_PAGE_CONFIG_CHANGE, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT, CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE, CMD_OPTIONS_GET_OBSERVABILITY_SNAPSHOT, diff --git a/src/core/domain/contracts/settings.ts b/src/core/domain/contracts/settings.ts index 5637538e..847d66f8 100644 --- a/src/core/domain/contracts/settings.ts +++ b/src/core/domain/contracts/settings.ts @@ -21,6 +21,7 @@ import { KEY_FALLBACK_LANGUAGE, KEY_INLINE_SUGGESTION, KEY_PREFIX_ONLY_MODE, + KEY_PERSONALIZATION_ENABLED, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, KEY_LANGUAGE, KEY_MIN_WORD_LENGTH_TO_PREDICT, @@ -58,6 +59,7 @@ export const SETTINGS_KEYS = { enabledLanguages: KEY_ENABLED_LANGUAGES, inlineSuggestion: KEY_INLINE_SUGGESTION, prefixOnlyMode: KEY_PREFIX_ONLY_MODE, + personalizationEnabled: KEY_PERSONALIZATION_ENABLED, preferNativeAutocomplete: KEY_PREFER_NATIVE_AUTOCOMPLETE, numSuggestions: KEY_NUM_SUGGESTIONS, minWordLengthToPredict: KEY_MIN_WORD_LENGTH_TO_PREDICT, @@ -113,6 +115,7 @@ export interface SettingsSchema { enabledLanguages: string[]; inlineSuggestion: boolean; prefixOnlyMode: boolean; + personalizationEnabled: boolean; preferNativeAutocomplete: boolean; numSuggestions: number; minWordLengthToPredict: number; diff --git a/src/core/domain/messageTypes.d.ts b/src/core/domain/messageTypes.d.ts index bdbd8a72..cefe0b67 100644 --- a/src/core/domain/messageTypes.d.ts +++ b/src/core/domain/messageTypes.d.ts @@ -3,6 +3,7 @@ import type { ObservabilityEvent, ObservabilitySnapshot, } from "./observability"; +import type { PersonalizationEvent } from "./personalization/types"; // Context for CMD_BACKGROUND_PAGE_SET_CONFIG export interface SuggestionThemeConfig { @@ -173,6 +174,8 @@ export interface PopupAckDonationMilestoneContext { } export type OptionsResetProductivityStatsContext = Record; +export type OptionsClearPersonalizationContext = Record; +export type ContentScriptPersonalizationEventContext = PersonalizationEvent; export type OptionsGetPredictorDebugSnapshotContext = Record; export type OptionsClearPredictorDebugTraceContext = Record; export type OptionsGetObservabilitySnapshotContext = Record; @@ -303,6 +306,10 @@ export type Message = command: "CMD_CONTENT_SCRIPT_USAGE_EVENT"; context: ContentScriptUsageEventContext; } + | { + command: "CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT"; + context: ContentScriptPersonalizationEventContext; + } | { command: "CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS"; context: ContentScriptRuntimeStatusContext; @@ -323,6 +330,10 @@ export type Message = command: "CMD_OPTIONS_RESET_PRODUCTIVITY_STATS"; context: OptionsResetProductivityStatsContext; } + | { + command: "CMD_OPTIONS_CLEAR_PERSONALIZATION"; + context: OptionsClearPersonalizationContext; + } | { command: "CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT"; context: OptionsGetPredictorDebugSnapshotContext; @@ -393,6 +404,14 @@ export type ContentScriptUsageEventMessage = Extract< Message, { command: "CMD_CONTENT_SCRIPT_USAGE_EVENT" } >; +export type ContentScriptPersonalizationEventMessage = Extract< + Message, + { command: "CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT" } +>; +export type OptionsClearPersonalizationMessage = Extract< + Message, + { command: "CMD_OPTIONS_CLEAR_PERSONALIZATION" } +>; export type ContentScriptRuntimeStatusMessage = Extract< Message, { command: "CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS" } diff --git a/tests/SuggestionEntrySession.test.ts b/tests/SuggestionEntrySession.test.ts index dc900bc8..57ca7c8c 100644 --- a/tests/SuggestionEntrySession.test.ts +++ b/tests/SuggestionEntrySession.test.ts @@ -51,6 +51,7 @@ function makeSession({ contentEditableAdapter = new ContentEditableAdapter(), getPendingFallback = () => undefined, recordSuggestionAccepted = jest.fn(), + recordPersonalizationAccepted = jest.fn(() => "accept-fixed"), getLang = () => "en_US", insertSpaceAfterAutocomplete = true, }: { @@ -98,6 +99,7 @@ function makeSession({ contentEditableAdapter?: ContentEditableAdapter; getPendingFallback?: () => PendingKeyFallback | undefined; recordSuggestionAccepted?: ReturnType; + recordPersonalizationAccepted?: ReturnType; getLang?: () => string; insertSpaceAfterAutocomplete?: boolean; } = {}): SuggestionEntrySession { @@ -119,6 +121,7 @@ function makeSession({ renderInline, recordSuggestionShown, recordSuggestionAccepted, + recordPersonalizationAccepted, getLang, insertSpaceAfterAutocomplete, logRenderedSuggestionPopup, @@ -377,6 +380,7 @@ test("session acceptance lifecycle applies accepted suggestion state", () => { syncManualAutoFixSuppression: jest.fn(), }; const recordSuggestionAccepted = jest.fn(); + const recordPersonalizationAccepted = jest.fn(() => "accept-fixed"); const clearPendingFallback = jest.fn(); const predictionCoordinator = { shouldProcessResponse: (_entry: SuggestionEntry, context: PredictionResponse) => @@ -397,6 +401,7 @@ test("session acceptance lifecycle applies accepted suggestion state", () => { predictionCoordinator, textEditService, recordSuggestionAccepted, + recordPersonalizationAccepted, insertSpaceAfterAutocomplete: true, getLang: () => "en_US", }); @@ -421,6 +426,11 @@ test("session acceptance lifecycle applies accepted suggestion state", () => { insertedText: "beta", language: "en_US", }); + expect(recordPersonalizationAccepted).toHaveBeenCalledWith({ + suggestion: "beta", + triggerText: "bet", + language: "en_US", + }); }); test("session skips delayed spacing when a block-scoped accepted word already has a following space", () => { diff --git a/tests/SuggestionPersonalizationService.test.ts b/tests/SuggestionPersonalizationService.test.ts new file mode 100644 index 00000000..17b5698a --- /dev/null +++ b/tests/SuggestionPersonalizationService.test.ts @@ -0,0 +1,58 @@ +import { jest } from "bun:test"; +import { SuggestionPersonalizationService } from "../src/adapters/chrome/content-script/suggestions/SuggestionPersonalizationService"; +import { CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT } from "../src/core/domain/constants"; + +describe("SuggestionPersonalizationService", () => { + test("emits minimal accepted and reverted events with the same event ID", () => { + const sendMessage = jest.fn((_, callback: () => void) => callback()); + const service = new SuggestionPersonalizationService({ + sendMessage, + readLastError: () => undefined, + createEventId: () => "accept-fixed", + }); + + const eventId = service.recordSuggestionAccepted({ + suggestion: "hello", + triggerText: "hel", + language: "en_US", + }); + service.recordSuggestionReverted(eventId); + + expect(sendMessage.mock.calls.map(([message]) => message)).toEqual([ + { + command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, + context: { + eventType: "suggestion_accepted", + eventId: "accept-fixed", + suggestion: "hello", + triggerText: "hel", + language: "en_US", + }, + }, + { + command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, + context: { + eventType: "suggestion_reverted", + eventId: "accept-fixed", + }, + }, + ]); + }); + + test("never breaks acceptance when runtime messaging is unavailable", () => { + const service = new SuggestionPersonalizationService({ + sendMessage: () => { + throw new Error("runtime unavailable"); + }, + createEventId: () => "accept-fixed", + }); + + expect(() => + service.recordSuggestionAccepted({ + suggestion: "hello", + triggerText: "hel", + language: "en_US", + }), + ).not.toThrow(); + }); +}); diff --git a/tests/SuggestionTextEditService.test.ts b/tests/SuggestionTextEditService.test.ts index 80b6334b..281a675c 100644 --- a/tests/SuggestionTextEditService.test.ts +++ b/tests/SuggestionTextEditService.test.ts @@ -1690,6 +1690,9 @@ describe("SuggestionTextEditService", () => { }); service.acceptSuggestion(entry, "hi "); + if (entry.pendingExtensionEdit) { + entry.pendingExtensionEdit.personalizationEventId = "accept-fixed"; + } expect(input.value).toBe("hi "); const keyboardEvent = new Event("keydown", { @@ -1702,10 +1705,12 @@ describe("SuggestionTextEditService", () => { event.preventDefault(); event.stopPropagation(); }; + const onSuccessfulUndo = jest.fn(); const handled = service.tryUndoLastExtensionEdit(entry, keyboardEvent, { consumeKeyboardEvent, clearSuggestions: () => undefined, + onSuccessfulUndo, }); expect(handled).toBe(true); @@ -1713,6 +1718,12 @@ describe("SuggestionTextEditService", () => { expect(input.selectionStart).toBe(1); expect(entry.pendingExtensionEdit).toBeNull(); expect(entry.manualAutoFixSuppression).toBeNull(); + expect(onSuccessfulUndo).toHaveBeenCalledWith( + expect.objectContaining({ + source: "suggestion", + personalizationEventId: "accept-fixed", + }), + ); }); test("undoes latest grammar auto-fix on Cmd/Ctrl+Z when caret is unchanged", () => { diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 01d63099..578d5542 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -8,9 +8,11 @@ import { CMD_CONTENT_SCRIPT_PREDICT_REQ, CMD_CONTENT_SCRIPT_REPORT_RUNTIME_STATUS, CMD_CONTENT_SCRIPT_USAGE_EVENT, + CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, CMD_GET_AUTO_LANGUAGE_STATUS, CMD_OPTIONS_PAGE_CONFIG_CHANGE, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_POPUP_ACK_DONATION_MILESTONE, CMD_POPUP_ACK_WEEKLY_RECAP, CMD_POPUP_GET_PRODUCTIVITY_STATS, @@ -101,6 +103,9 @@ const backgroundHarnessMocks = { isEnabledForDomain: jest.fn(async () => true), logError: jest.fn(), migrateToLocalStore: jest.fn(async () => undefined), + personalizationInitialize: jest.fn(async () => undefined), + personalizationHandleEvent: jest.fn(async () => true), + personalizationClear: jest.fn(async () => undefined), }; function installBackgroundHarnessModuleMocks(): void { @@ -136,6 +141,19 @@ function installBackgroundHarnessModuleMocks(): void { })), })); + jest.unstable_mockModule( + "../src/core/application/personalization/PersonalizationService", + () => ({ + PersonalizationService: jest.fn().mockImplementation(() => ({ + initialize: () => backgroundHarnessMocks.personalizationInitialize(), + handleEvent: (...args: [unknown]) => + backgroundHarnessMocks.personalizationHandleEvent(...args), + clear: () => backgroundHarnessMocks.personalizationClear(), + getRankingSnapshot: () => ({}), + })), + }), + ); + jest.unstable_mockModule("../src/adapters/chrome/background/TabMessenger", () => ({ TabMessenger: jest.fn().mockImplementation(() => ({ sendToAllTabs: (...args: [unknown, unknown?, unknown?]) => @@ -279,6 +297,9 @@ async function loadBackgroundHarness(stateOverrides: Record = { const isEnabledForDomain = jest.fn(async () => true); const logError = jest.fn(); const migrateToLocalStore = jest.fn(async () => undefined); + const personalizationInitialize = jest.fn(async () => undefined); + const personalizationHandleEvent = jest.fn(async () => true); + const personalizationClear = jest.fn(async () => undefined); const onInstalledAddListener = jest.fn(); const onCommandAddListener = jest.fn(); @@ -341,6 +362,9 @@ async function loadBackgroundHarness(stateOverrides: Record = { backgroundHarnessMocks.isEnabledForDomain = isEnabledForDomain; backgroundHarnessMocks.logError = logError; backgroundHarnessMocks.migrateToLocalStore = migrateToLocalStore; + backgroundHarnessMocks.personalizationInitialize = personalizationInitialize; + backgroundHarnessMocks.personalizationHandleEvent = personalizationHandleEvent; + backgroundHarnessMocks.personalizationClear = personalizationClear; const { BackgroundServiceWorker } = await import("../src/adapters/chrome/background/BackgroundServiceWorker"); @@ -384,6 +408,9 @@ async function loadBackgroundHarness(stateOverrides: Record = { isEnabledForDomain, logError, migrateToLocalStore, + personalizationInitialize, + personalizationHandleEvent, + personalizationClear, onInstalled, onCommand, onMessage, @@ -405,6 +432,7 @@ describe("background routing and lifecycle", () => { expect(harness.migrateToLocalStore).toHaveBeenCalledWith("2025.12.0"); expect(harness.predictionInitialize).toHaveBeenCalled(); + expect(harness.personalizationInitialize).toHaveBeenCalled(); expect(harness.predictionSetConfig).toHaveBeenCalledWith( expect.objectContaining({ aiPredictorEnabled: false, @@ -1279,6 +1307,44 @@ describe("background routing and lifecycle", () => { expect(resetResponse).toHaveBeenCalledWith({ ok: true }); }); + test("routes personalization acceptance, reversal, and clear commands", async () => { + const harness = await loadBackgroundHarness(); + const sendResponse = jest.fn(); + const acceptance = { + eventType: "suggestion_accepted", + eventId: "accept-1", + suggestion: "hello", + triggerText: "hel", + language: "en_US", + }; + + harness.onMessage( + { command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, context: acceptance }, + { tab: { id: 1 } as chrome.tabs.Tab, frameId: 0 }, + sendResponse, + ); + await flushPromises(); + expect(harness.personalizationHandleEvent).toHaveBeenCalledWith(acceptance); + expect(sendResponse).toHaveBeenCalledWith({ ok: true }); + + const reversal = { eventType: "suggestion_reverted", eventId: "accept-1" }; + harness.onMessage( + { command: CMD_CONTENT_SCRIPT_PERSONALIZATION_EVENT, context: reversal }, + { tab: { id: 1 } as chrome.tabs.Tab, frameId: 0 }, + sendResponse, + ); + await flushPromises(); + expect(harness.personalizationHandleEvent).toHaveBeenCalledWith(reversal); + + harness.onMessage( + { command: CMD_OPTIONS_CLEAR_PERSONALIZATION, context: {} }, + {} as chrome.runtime.MessageSender, + sendResponse, + ); + await flushPromises(); + expect(harness.personalizationClear).toHaveBeenCalledTimes(1); + }); + // --------------------------------------------------------------------------- // Performance regression tests // --------------------------------------------------------------------------- From 4cba73d665747a00e2bdac5ed9810e1070357a1b Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 13:39:40 +0200 Subject: [PATCH 4/6] feat: rerank Presage candidates from local history --- .../background/BackgroundServiceWorker.ts | 4 +- .../chrome/background/PredictionManager.ts | 13 +- .../chrome/background/PresageHandler.ts | 46 ++++++- .../background/config/ConfigAssembler.ts | 3 + tests/ConfigAssembler.prefixOnly.test.ts | 17 +++ tests/PresageHandler.live.test.ts | 30 ++++- tests/PresageHandler.personalization.test.ts | 121 ++++++++++++++++++ 7 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 tests/PresageHandler.personalization.test.ts diff --git a/src/adapters/chrome/background/BackgroundServiceWorker.ts b/src/adapters/chrome/background/BackgroundServiceWorker.ts index 178abefc..fae6f8d7 100644 --- a/src/adapters/chrome/background/BackgroundServiceWorker.ts +++ b/src/adapters/chrome/background/BackgroundServiceWorker.ts @@ -77,7 +77,9 @@ export class BackgroundServiceWorker { }, }); this.languageDetector = new LanguageDetector(this.settingsManager); - this.predictionManager = new PredictionManager(); + this.predictionManager = new PredictionManager({ + getPersonalizationSnapshot: () => this.personalizationService.getRankingSnapshot(), + }); this.tabMessenger = new TabMessenger(); this.productivityStatsManager = new ProductivityStatsManager(this.settingsManager); this.observabilityService = new ObservabilityService({ diff --git a/src/adapters/chrome/background/PredictionManager.ts b/src/adapters/chrome/background/PredictionManager.ts index 1ce3d23c..3a296e3b 100644 --- a/src/adapters/chrome/background/PredictionManager.ts +++ b/src/adapters/chrome/background/PredictionManager.ts @@ -15,6 +15,11 @@ import { WebLLMPredictor } from "./WebLLMPredictor"; import { createLogger } from "@core/application/logging/Logger"; import { DEFAULT_AI_PREDICTION_TIMEOUT_MS } from "@core/domain/constants"; import { PredictorError, getErrorMessage } from "@core/domain/error"; +import type { PersonalizationRankingSnapshot } from "@core/domain/personalization/types"; + +interface PredictionManagerOptions { + getPersonalizationSnapshot?: () => PersonalizationRankingSnapshot; +} export interface PredictionDebugRequestMeta { traceId?: string; @@ -97,9 +102,11 @@ export class PredictionManager { private debugTraces: PredictorDebugTrace[] = []; private debugTraceById: Map = new Map(); private currentConfig: PredictionConfig | null = null; + private readonly getPersonalizationSnapshot: () => PersonalizationRankingSnapshot; - constructor() { + constructor(options: PredictionManagerOptions = {}) { this.libPresageMod = libPresageMod as () => Promise; + this.getPersonalizationSnapshot = options.getPersonalizationSnapshot ?? (() => ({})); void this.initialize(); } @@ -113,7 +120,9 @@ export class PredictionManager { private async _doInitializePresage(): Promise { try { const Module = await this.libPresageMod(); - this.presageHandler = new PresageHandler(Module); + this.presageHandler = new PresageHandler(Module, { + getPersonalizationSnapshot: this.getPersonalizationSnapshot, + }); this.predictionOrchestrator = new PredictionOrchestrator( this.presageHandler, this.getWebLLMPredictor(), diff --git a/src/adapters/chrome/background/PresageHandler.ts b/src/adapters/chrome/background/PresageHandler.ts index 9521ab61..90f146c0 100644 --- a/src/adapters/chrome/background/PresageHandler.ts +++ b/src/adapters/chrome/background/PresageHandler.ts @@ -13,6 +13,8 @@ import { PresageEngine } from "./PresageEngine"; import { MAX_NUM_SUGGESTIONS } from "@core/domain/constants"; import type { PredictionResult } from "./PredictionTypes"; import { SPACING_RULES, Spacing } from "@core/domain/spacingRules"; +import { rankPersonalizedCandidates } from "@core/domain/personalization/PersonalizationRanker"; +import type { PersonalizationRankingSnapshot } from "@core/domain/personalization/types"; const SUGGESTION_COUNT = 5; const MIN_WORD_LENGTH_TO_PREDICT = 1; const logger = createLogger("PresageHandler"); @@ -25,12 +27,18 @@ export interface PresageConfig { autoCapitalize: boolean; textExpansions: Array<[string, object]>; prefixOnlyMode: boolean; + personalizationEnabled?: boolean; timeFormat?: string; dateFormat?: string; userDictionaryList?: string[]; } +interface PresageHandlerOptions { + getPersonalizationSnapshot?: () => PersonalizationRankingSnapshot; + now?: () => number; +} + export interface PresagePredictionContext { text: string; nextChar: string; @@ -61,9 +69,13 @@ export class PresageHandler { private dateFormat?: string; private engineNumSuggestions: number; private textExpansionsSignature = ""; + private textExpansionShortcuts = new Set(); private userDictionarySignature = ""; + private personalizationEnabled = false; + private readonly getPersonalizationSnapshot: () => PersonalizationRankingSnapshot; + private readonly now: () => number; - constructor(Module: PresageModule) { + constructor(Module: PresageModule, options: PresageHandlerOptions = {}) { const engineConfig: PresageEngineConfig = { numSuggestions: SUGGESTION_COUNT, prefixOnlyMode: false, @@ -77,6 +89,8 @@ export class PresageHandler { this.autoCapitalize = true; this.prefixOnlyMode = false; this.userDictionaryList = []; + this.getPersonalizationSnapshot = options.getPersonalizationSnapshot ?? (() => ({})); + this.now = options.now ?? Date.now; this.predictionInputProcessor = new PredictionInputProcessor( this.minWordLengthToPredict, @@ -116,6 +130,10 @@ export class PresageHandler { this.insertSpaceAfterAutocomplete = config.insertSpaceAfterAutocomplete; this.autoCapitalize = config.autoCapitalize; this.prefixOnlyMode = config.prefixOnlyMode; + this.personalizationEnabled = config.personalizationEnabled ?? false; + this.textExpansionShortcuts = new Set( + (config.textExpansions ?? []).map(([shortcut]) => shortcut.trim().toLocaleLowerCase()), + ); this.timeFormat = config.timeFormat; this.dateFormat = config.dateFormat; @@ -235,7 +253,26 @@ export class PresageHandler { ) { return []; } - return this.doPredictionHandler(context.predictionInput, context.lang, context.tabId); + const predictions = await this.doPredictionHandler( + context.predictionInput, + context.lang, + context.tabId, + ); + if (!this.personalizationEnabled || this.isTextExpansionRequest(context.predictionInput)) { + return predictions; + } + + const inputLower = context.predictionInput.trim().toLocaleLowerCase(); + const pinnedCandidates = new Set( + predictions.filter((candidate) => candidate.toLocaleLowerCase() === inputLower), + ); + return rankPersonalizedCandidates({ + candidates: predictions, + language: context.lang, + snapshot: this.getPersonalizationSnapshot(), + nowMs: this.now(), + pinnedCandidates, + }); } finalizePrediction( @@ -334,4 +371,9 @@ export class PresageHandler { presageEngine.reinitialize(); } } + + private isTextExpansionRequest(predictionInput: string): boolean { + const finalToken = predictionInput.trim().split(/\s+/u).at(-1)?.toLocaleLowerCase(); + return finalToken ? this.textExpansionShortcuts.has(finalToken) : false; + } } diff --git a/src/adapters/chrome/background/config/ConfigAssembler.ts b/src/adapters/chrome/background/config/ConfigAssembler.ts index 0f2c040a..8fbfe60a 100644 --- a/src/adapters/chrome/background/config/ConfigAssembler.ts +++ b/src/adapters/chrome/background/config/ConfigAssembler.ts @@ -109,6 +109,7 @@ export class ConfigAssembler { observability, prefixOnlyMode, inlineSuggestion, + personalizationEnabled, ] = await Promise.all([ this.coreSettingsRepository.getNumSuggestions(), this.coreSettingsRepository.getMinWordLengthToPredict(), @@ -123,6 +124,7 @@ export class ConfigAssembler { this.getObservabilityConfig(), this.coreSettingsRepository.getPrefixOnlyMode(), this.coreSettingsRepository.getInlineSuggestion(), + this.coreSettingsRepository.getPersonalizationEnabled(), ]); const normalizedGrammarRules = normalizeGrammarRuleSelection(enabledGrammarRules); const autoCapitalize = normalizedGrammarRules.includes("capitalizeSentenceStart"); @@ -139,6 +141,7 @@ export class ConfigAssembler { autoCapitalize, textExpansions, prefixOnlyMode: prefixOnlyMode || inlineSuggestion, + personalizationEnabled, timeFormat, dateFormat, diff --git a/tests/ConfigAssembler.prefixOnly.test.ts b/tests/ConfigAssembler.prefixOnly.test.ts index 5c8d4bfc..66bdc961 100644 --- a/tests/ConfigAssembler.prefixOnly.test.ts +++ b/tests/ConfigAssembler.prefixOnly.test.ts @@ -25,6 +25,7 @@ describe("ConfigAssembler.assemblePredictionRuntimeConfig prefixOnlyMode", () => aiPredictionTimeoutMs: 120, debugPresagePredictorEnabled: true, debugAiPredictorEnabled: true, + personalizationEnabled: false, }; test("prefixOnlyMode=false, inlineSuggestion=false → false", async () => { @@ -68,4 +69,20 @@ describe("ConfigAssembler.assemblePredictionRuntimeConfig prefixOnlyMode", () => const result = await assembler.assemblePredictionRuntimeConfig(); expect(result.predictionConfig.prefixOnlyMode).toBe(true); }); + + test("passes opt-in personalization state to prediction config", async () => { + const sm = createSettingsManagerMock({ + ...baseSettings, + prefixOnlyMode: false, + inline_suggestion: false, + personalizationEnabled: true, + }); + const assembler = new ConfigAssembler(sm, { + enableAIPredictor: false, + isDevBuild: false, + }); + + const result = await assembler.assemblePredictionRuntimeConfig(); + expect(result.predictionConfig.personalizationEnabled).toBe(true); + }); }); diff --git a/tests/PresageHandler.live.test.ts b/tests/PresageHandler.live.test.ts index af8d28b8..806108c0 100644 --- a/tests/PresageHandler.live.test.ts +++ b/tests/PresageHandler.live.test.ts @@ -17,13 +17,15 @@ function createLiveConfig(textExpansions: Array<[string, string]>) { }; } -async function createLiveHandler(): Promise { +async function createLiveHandler( + options?: ConstructorParameters[1], +): Promise { const root = process.cwd(); const Module = await libPresageMod({ wasmBinary: readFileSync(`${root}/src/third_party/libpresage/libpresage.wasm`), locateFile: (name: string) => `${root}/public/third_party/libpresage/${name}`, }); - return new PresageHandler(Module); + return new PresageHandler(Module, options); } describe("PresageHandler live user dictionary", () => { @@ -117,3 +119,27 @@ describe("PresageHandler live text expansion config refresh", () => { ); }); }); + +describe("PresageHandler live personalized ranking", () => { + test("promotes an existing tenth Presage candidate before the visible cutoff", async () => { + const handler = await createLiveHandler({ + getPersonalizationSnapshot: () => ({ + en_US: { + through: { display: "through", score: 3, updatedAtMs: 1_000 }, + }, + }), + now: () => 1_000, + }); + handler.setConfig({ + ...createLiveConfig([]), + numSuggestions: 3, + engineNumSuggestions: 10, + insertSpaceAfterAutocomplete: false, + personalizationEnabled: true, + }); + + const result = await handler.runPrediction("th", "", "en_US"); + + expect(result.predictions).toEqual(["through", "the", "that"]); + }); +}); diff --git a/tests/PresageHandler.personalization.test.ts b/tests/PresageHandler.personalization.test.ts new file mode 100644 index 00000000..8fb59956 --- /dev/null +++ b/tests/PresageHandler.personalization.test.ts @@ -0,0 +1,121 @@ +import { mod } from "./fakeLibPresage.js"; +import { PresageHandler } from "../src/adapters/chrome/background/PresageHandler"; + +function createConfig(overrides: Partial[0]> = {}) { + return { + numSuggestions: 2, + engineNumSuggestions: 10, + minWordLengthToPredict: 0, + insertSpaceAfterAutocomplete: false, + autoCapitalize: false, + textExpansions: [], + prefixOnlyMode: false, + personalizationEnabled: false, + timeFormat: "", + dateFormat: "", + userDictionaryList: [], + ...overrides, + }; +} + +describe("PresageHandler personalized candidate pool", () => { + test("disabled mode preserves the baseline ordered result exactly", async () => { + mod.PresageCallback.predictions = ["alpha", "beta", "gamma"]; + const handler = new PresageHandler(mod, { + getPersonalizationSnapshot: () => ({ + en_US: { + gamma: { display: "gamma", score: 10, updatedAtMs: 1_000 }, + }, + }), + now: () => 1_000, + }); + handler.setConfig(createConfig({ personalizationEnabled: false })); + + await expect(handler.runPrediction("a", "", "en_US")).resolves.toEqual({ + predictions: ["alpha", "beta"], + }); + }); + + test("promotes a learned candidate from below the visible cutoff", async () => { + mod.PresageCallback.predictions = ["alpha", "beta", "gamma", "garden"]; + const snapshotProvider = jest.fn(() => ({ + en_US: { + gamma: { display: "gamma", score: 3, updatedAtMs: 1_000 }, + }, + })); + const handler = new PresageHandler(mod, { + getPersonalizationSnapshot: snapshotProvider, + now: () => 1_000, + }); + handler.setConfig(createConfig({ personalizationEnabled: true })); + + await expect(handler.runPrediction("a", "", "en_US")).resolves.toEqual({ + predictions: ["gamma", "alpha"], + }); + expect(snapshotProvider).toHaveBeenCalledTimes(1); + }); + + test("keeps exact matches pinned ahead of learned candidates", async () => { + mod.PresageCallback.predictions = ["gamma", "beta", "alpha"]; + const handler = new PresageHandler(mod, { + getPersonalizationSnapshot: () => ({ + en_US: { + gamma: { display: "gamma", score: 5, updatedAtMs: 1_000 }, + }, + }), + now: () => 1_000, + }); + handler.setConfig(createConfig({ personalizationEnabled: true })); + + await expect(handler.runPrediction("alpha", "", "en_US")).resolves.toEqual({ + predictions: ["alpha", "gamma"], + }); + }); + + test("leaves configured text expansion ordering untouched", async () => { + mod.PresageCallback.predictions = ["expansion output", "gamma", "alpha"]; + const snapshotProvider = jest.fn(() => ({ + en_US: { + gamma: { display: "gamma", score: 5, updatedAtMs: 1_000 }, + }, + })); + const handler = new PresageHandler(mod, { + getPersonalizationSnapshot: snapshotProvider, + now: () => 1_000, + }); + handler.setConfig( + createConfig({ + personalizationEnabled: true, + textExpansions: [["asap", "expansion output" as unknown as object]], + }), + ); + + await expect(handler.runPrediction("asap", "", "en_US")).resolves.toEqual({ + predictions: ["expansion output", "gamma"], + }); + expect(snapshotProvider).not.toHaveBeenCalled(); + }); + + test("keeps capitalization and spacing transformations after ranking", async () => { + mod.PresageCallback.predictions = ["alpha", "gamma"]; + const handler = new PresageHandler(mod, { + getPersonalizationSnapshot: () => ({ + en_US: { + gamma: { display: "gamma", score: 5, updatedAtMs: 1_000 }, + }, + }), + now: () => 1_000, + }); + handler.setConfig( + createConfig({ + personalizationEnabled: true, + insertSpaceAfterAutocomplete: true, + autoCapitalize: true, + }), + ); + + await expect(handler.runPrediction("A", "", "en_US")).resolves.toEqual({ + predictions: ["Gamma ", "Alpha "], + }); + }); +}); From aa5ee413f6fa4cdae4f15a4981c51bd1a2c62f77 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 13:55:22 +0200 Subject: [PATCH 5/6] feat: expose personalization controls and coverage --- src/ui/options/DataDiagnosticsPanel.ts | 1 + src/ui/options/EssentialsWorkspacePanel.ts | 2 + src/ui/options/fluenttyperI18n.ts | 66 +++++ src/ui/options/settings.ts | 49 +++- src/ui/options/settingsManifest.ts | 20 ++ tests/CoreSettingsRepository.test.ts | 10 + tests/e2e/coverage-baseline-ids.json | 6 +- tests/e2e/coverage-matrix.json | 44 +++- tests/e2e/full.e2e.test.ts | 266 +++++++++++++++++++++ tests/options.page.test.ts | 26 ++ tests/options.personalization.test.ts | 25 ++ tests/optionsWorkspacePanels.test.ts | 7 + 12 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 tests/options.personalization.test.ts diff --git a/src/ui/options/DataDiagnosticsPanel.ts b/src/ui/options/DataDiagnosticsPanel.ts index d6e9ce03..6bccc991 100644 --- a/src/ui/options/DataDiagnosticsPanel.ts +++ b/src/ui/options/DataDiagnosticsPanel.ts @@ -34,6 +34,7 @@ export class DataDiagnosticsPanel { ); moveControlToBody(this.registry, "importSettingButton", config.body); moveControlToBody(this.registry, "exportSettingButton", config.body); + moveControlToBody(this.registry, "clearPersonalizationButton", config.body); shell.appendChild(config.card); this.root.replaceChildren(shell); diff --git a/src/ui/options/EssentialsWorkspacePanel.ts b/src/ui/options/EssentialsWorkspacePanel.ts index 753e0414..47881732 100644 --- a/src/ui/options/EssentialsWorkspacePanel.ts +++ b/src/ui/options/EssentialsWorkspacePanel.ts @@ -8,6 +8,7 @@ import { KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, KEY_MIN_WORD_LENGTH_TO_PREDICT, KEY_NUM_SUGGESTIONS, + KEY_PERSONALIZATION_ENABLED, KEY_PREFER_NATIVE_AUTOCOMPLETE, KEY_PREFIX_ONLY_MODE, KEY_SELECT_BY_DIGIT, @@ -42,6 +43,7 @@ export class EssentialsWorkspacePanel { moveControlToBody(this.registry, KEY_PREFIX_ONLY_MODE, general.body); const prediction = createWorkspaceCard(i18n.get("prediction_engine")); + moveControlToBody(this.registry, KEY_PERSONALIZATION_ENABLED, prediction.body); moveControlToBody(this.registry, KEY_NUM_SUGGESTIONS, prediction.body); moveControlToBody(this.registry, KEY_MIN_WORD_LENGTH_TO_PREDICT, prediction.body); if (this.isDevBuild) { diff --git a/src/ui/options/fluenttyperI18n.ts b/src/ui/options/fluenttyperI18n.ts index 3e153bda..a7026535 100644 --- a/src/ui/options/fluenttyperI18n.ts +++ b/src/ui/options/fluenttyperI18n.ts @@ -3209,6 +3209,28 @@ i18n.extend({ pl: "Sugeruj tylko słowa zaczynające się od wpisanego tekstu. Wyłącza sugestie korekty pisowni. Automatycznie włączane przy podpowiedziach inline.", pr: "Sugerir apenas palavras que começam com o que você digita. Desativa sugestões de correção ortográfica. Ativado automaticamente com sugestão inline.", }, + personalization_enabled_label: { + en: "Learn from accepted suggestions", + fr: "Apprendre des suggestions acceptées", + hr: "Uči iz prihvaćenih prijedloga", + es: "Aprender de las sugerencias aceptadas", + el: "Μάθηση από αποδεκτές προτάσεις", + sv: "Lär av accepterade förslag", + de: "Aus angenommenen Vorschlägen lernen", + pl: "Ucz się z zaakceptowanych sugestii", + pr: "Aprender com sugestões aceitas", + }, + personalization_enabled_desc: { + en: "Promote words you frequently choose. Learned words stay on this device and can be cleared at any time.", + fr: "Met en avant les mots souvent choisis. Les mots appris restent sur cet appareil et peuvent être effacés à tout moment.", + hr: "Promiče riječi koje često birate. Naučene riječi ostaju na ovom uređaju i mogu se izbrisati u bilo kojem trenutku.", + es: "Promueve las palabras que eliges con frecuencia. Las palabras aprendidas permanecen en este dispositivo y se pueden borrar en cualquier momento.", + el: "Προωθεί λέξεις που επιλέγετε συχνά. Οι εκμαθημένες λέξεις παραμένουν σε αυτή τη συσκευή και μπορούν να διαγραφούν ανά πάσα στιγμή.", + sv: "Lyfter fram ord du ofta väljer. Inlärda ord stannar på den här enheten och kan rensas när som helst.", + de: "Bevorzugt häufig gewählte Wörter. Gelernte Wörter bleiben auf diesem Gerät und können jederzeit gelöscht werden.", + pl: "Promuje często wybierane słowa. Wyuczone słowa pozostają na tym urządzeniu i można je usunąć w dowolnym momencie.", + pr: "Promove palavras que você escolhe com frequência. As palavras aprendidas ficam neste dispositivo e podem ser apagadas a qualquer momento.", + }, prefer_native_autocomplete_label: { en: "Prefer native autocomplete in conflict fields", }, @@ -4739,6 +4761,50 @@ i18n.extend({ pl: "Czyści lokalne metryki produktywności i zaczyna śledzenie od zera.", pr: "Limpa métricas locais de produtividade e reinicia o acompanhamento do zero.", }, + clear_personalization_btn: { + en: "Clear learned words", + fr: "Effacer les mots appris", + hr: "Izbriši naučene riječi", + es: "Borrar palabras aprendidas", + el: "Διαγραφή εκμαθημένων λέξεων", + sv: "Rensa inlärda ord", + de: "Gelernte Wörter löschen", + pl: "Wyczyść wyuczone słowa", + pr: "Limpar palavras aprendidas", + }, + clear_personalization_desc: { + en: "Remove only local suggestion-personalization history.", + fr: "Supprime uniquement l'historique local de personnalisation des suggestions.", + hr: "Uklanja samo lokalnu povijest personalizacije prijedloga.", + es: "Elimina solo el historial local de personalización de sugerencias.", + el: "Καταργεί μόνο το τοπικό ιστορικό εξατομίκευσης προτάσεων.", + sv: "Tar endast bort lokal historik för förslagsanpassning.", + de: "Entfernt nur den lokalen Verlauf der Vorschlagspersonalisierung.", + pl: "Usuwa tylko lokalną historię personalizacji sugestii.", + pr: "Remove apenas o histórico local de personalização de sugestões.", + }, + clear_personalization_confirm: { + en: "Clear all learned words? This cannot be undone.", + fr: "Effacer tous les mots appris ? Cette action est irréversible.", + hr: "Izbrisati sve naučene riječi? Ovu radnju nije moguće poništiti.", + es: "¿Borrar todas las palabras aprendidas? Esta acción no se puede deshacer.", + el: "Διαγραφή όλων των εκμαθημένων λέξεων; Αυτή η ενέργεια δεν αναιρείται.", + sv: "Rensa alla inlärda ord? Detta kan inte ångras.", + de: "Alle gelernten Wörter löschen? Dies kann nicht rückgängig gemacht werden.", + pl: "Wyczyścić wszystkie wyuczone słowa? Tej operacji nie można cofnąć.", + pr: "Limpar todas as palavras aprendidas? Esta ação não pode ser desfeita.", + }, + clear_personalization_success: { + en: "Learned words cleared.", + fr: "Mots appris effacés.", + hr: "Naučene riječi su izbrisane.", + es: "Palabras aprendidas borradas.", + el: "Οι εκμαθημένες λέξεις διαγράφηκαν.", + sv: "Inlärda ord har rensats.", + de: "Gelernte Wörter wurden gelöscht.", + pl: "Wyuczone słowa zostały wyczyszczone.", + pr: "Palavras aprendidas apagadas.", + }, popup_advanced_options: { en: "Advanced Options", fr: "Options avancées", diff --git a/src/ui/options/settings.ts b/src/ui/options/settings.ts index 741052f1..5c5af2d4 100644 --- a/src/ui/options/settings.ts +++ b/src/ui/options/settings.ts @@ -53,6 +53,7 @@ import { KEY_DISPLAY_LANG_HEADER, KEY_INLINE_SUGGESTION, KEY_PREFIX_ONLY_MODE, + KEY_PERSONALIZATION_ENABLED, KEY_EXTENSION_LANGUAGE, KEY_SITE_PROFILES, KEY_ENABLED_GRAMMAR_RULES, @@ -81,11 +82,13 @@ import { CMD_OPTIONS_CLEAR_OBSERVABILITY_EVENTS, CMD_OPTIONS_GET_OBSERVABILITY_SNAPSHOT, CMD_OPTIONS_RESET_PRODUCTIVITY_STATS, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT, CMD_OPTIONS_CLEAR_PREDICTOR_DEBUG_TRACE, CMD_OPTIONS_REPORT_OBSERVABILITY_EVENT, CMD_OPTIONS_REPORT_OBSERVABILITY_MODULES, } from "@core/domain/constants"; +import { PERSONALIZATION_STORAGE_KEY } from "@core/application/personalization/PersonalizationRepository"; import { DEFAULT_SUGGESTION_THEME_SETTINGS } from "@core/domain/themeDefaults"; import { i18n } from "./fluenttyperI18n.js"; import { manifest } from "./settingsManifest.js"; @@ -189,6 +192,7 @@ const CONFIG_REFRESH_KEYS = [ KEY_USER_DICTIONARY_LIST, KEY_DISPLAY_LANG_HEADER, KEY_INLINE_SUGGESTION, + KEY_PERSONALIZATION_ENABLED, KEY_EXTENSION_LANGUAGE, KEY_AI_PREDICTOR_ENABLED, KEY_AI_MODEL_ID, @@ -278,7 +282,7 @@ function wireValidationHandlers(registry: SettingsRegistry, store: Store): void function wireImportExportHandlers(registry: SettingsRegistry): void { registry.exportSettingButton.addEvent("action", function () { chrome.storage.local.get(null, function (items) { - const result = JSON.stringify(items); + const result = JSON.stringify(createSettingsExportSnapshot(items)); const blob = new Blob([result], { type: "application/json" }); const exportFilename = "FluentTyperSettings.json"; const dlink = document.createElement("a"); @@ -438,8 +442,9 @@ function importSettingButtonFileSelected( const fr = new FileReader(); fr.addEventListener("load", () => { try { - const jsonSettings = JSON.parse(fr.result as string) as Record; - delete jsonSettings["store.settings.revertOnBackspace"]; + const jsonSettings = sanitizeSettingsImportSnapshot( + JSON.parse(fr.result as string) as Record, + ); void chrome.storage.local.set(jsonSettings); dispatchSettingsSaveStatus("saved", { message: i18n.get("settings_imported") }); optionsPageConfigChange(); @@ -459,6 +464,23 @@ function importSettingButtonFileSelected( importInputElem.value = ""; } +export function createSettingsExportSnapshot( + items: Record, +): Record { + const exportableItems = { ...items }; + delete exportableItems[PERSONALIZATION_STORAGE_KEY]; + return exportableItems; +} + +export function sanitizeSettingsImportSnapshot( + items: Record, +): Record { + const importableItems = { ...items }; + delete importableItems["store.settings.revertOnBackspace"]; + delete importableItems[PERSONALIZATION_STORAGE_KEY]; + return importableItems; +} + const themePresets = { default: { ...DEFAULT_SUGGESTION_THEME_SETTINGS }, compact: { @@ -2956,6 +2978,27 @@ window.addEventListener("DOMContentLoaded", function () { } })(); }); + registry.clearPersonalizationButton.addEvent("action", function () { + if (!window.confirm(i18n.get("clear_personalization_confirm"))) { + return; + } + void (async () => { + const response = await sendRuntimeMessage({ + command: CMD_OPTIONS_CLEAR_PERSONALIZATION, + context: {}, + }); + if ( + response && + typeof response === "object" && + !Array.isArray(response) && + (response as { ok?: boolean }).ok + ) { + dispatchSettingsSaveStatus("saved", { + message: i18n.get("clear_personalization_success"), + }); + } + })(); + }); wireImportExportHandlers(registry); wireRuntimeSettingsHandlers(registry); diff --git a/src/ui/options/settingsManifest.ts b/src/ui/options/settingsManifest.ts index f1f82316..d0a6da32 100644 --- a/src/ui/options/settingsManifest.ts +++ b/src/ui/options/settingsManifest.ts @@ -50,6 +50,7 @@ import { KEY_SUGGESTION_PADDING_HORIZONTAL, KEY_INLINE_SUGGESTION, KEY_PREFIX_ONLY_MODE, + KEY_PERSONALIZATION_ENABLED, DEFAULT_NUM_SUGGESTIONS, DEFAULT_AI_MODEL_ID, DEFAULT_AI_PREDICTION_TIMEOUT_MS, @@ -362,6 +363,17 @@ const manifest: ManifestDefinition = { label: buildFieldLabel(i18n.get("prefix_only_mode_label"), i18n.get("prefix_only_mode_desc")), default: false, }, + { + tab: "core_settings", + group: i18n.get("prediction_engine"), + name: KEY_PERSONALIZATION_ENABLED, + type: "checkbox", + label: buildFieldLabel( + i18n.get("personalization_enabled_label"), + i18n.get("personalization_enabled_desc"), + ), + default: false, + }, { tab: "core_settings", group: i18n.get("prediction_engine"), @@ -759,6 +771,14 @@ const manifest: ManifestDefinition = { text: i18n.get("reset_productivity_stats_btn"), label: i18n.get("reset_productivity_stats_desc"), }, + { + tab: "advanced_tab", + group: i18n.get("config_data"), + name: "clearPersonalizationButton", + type: "button", + text: i18n.get("clear_personalization_btn"), + label: i18n.get("clear_personalization_desc"), + }, { tab: "advanced_tab", group: i18n.get("config_data"), diff --git a/tests/CoreSettingsRepository.test.ts b/tests/CoreSettingsRepository.test.ts index 717b2111..3f2a73a9 100644 --- a/tests/CoreSettingsRepository.test.ts +++ b/tests/CoreSettingsRepository.test.ts @@ -49,6 +49,16 @@ describe("CoreSettingsRepository", () => { await expect(repository.getPrefixOnlyMode()).resolves.toBe(false); }); + test("keeps personalization opt-in", async () => { + const defaults = new CoreSettingsRepository(createSettingsManagerMock({})); + const enabled = new CoreSettingsRepository( + createSettingsManagerMock({ personalizationEnabled: true }), + ); + + await expect(defaults.getPersonalizationEnabled()).resolves.toBe(false); + await expect(enabled.getPersonalizationEnabled()).resolves.toBe(true); + }); + test("keeps object entries and filters invalid rows", async () => { const repository = new CoreSettingsRepository( createSettingsManagerMock({ diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json index 0912e165..06ca935f 100644 --- a/tests/e2e/coverage-baseline-ids.json +++ b/tests/e2e/coverage-baseline-ids.json @@ -1,6 +1,6 @@ { "version": 1, - "capturedAt": "2026-03-05", + "capturedAt": "2026-07-28", "baselineBehaviorIds": [ "install_page_reachable", "popup_page_loads", @@ -76,6 +76,8 @@ "input_type_eligibility", "disabled_input_dynamic_reattach", "shadow_dom_discovery", - "shadow_dom_late_attach" + "shadow_dom_late_attach", + "personalized_suggestion_ranking", + "personalization_local_privacy_controls" ] } diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json index 4950c05e..2e0b0a9c 100644 --- a/tests/e2e/coverage-matrix.json +++ b/tests/e2e/coverage-matrix.json @@ -1,6 +1,6 @@ { "version": 1, - "capturedAt": "2026-03-05", + "capturedAt": "2026-07-28", "behaviors": [ { "id": "install_page_reachable", @@ -1256,6 +1256,48 @@ "test": "discovers input in shadow root created on a host already in the DOM" } ] + }, + { + "id": "personalized_suggestion_ranking", + "description": "Repeated local suggestion acceptance promotes an existing Presage candidate in later menu and inline results, persists across runtime restart, and remains isolated from disabled ordering.", + "coverage": [ + { + "layer": "unit", + "file": "tests/PersonalizationRanker.test.ts", + "test": "promotes eligible candidates by score without mutating input" + }, + { + "layer": "integration", + "file": "tests/PresageHandler.live.test.ts", + "test": "promotes an existing tenth Presage candidate before the visible cutoff" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Personalized menu and inline ranking survives runtime restart and clears locally" + } + ] + }, + { + "id": "personalization_local_privacy_controls", + "description": "Personalization is opt-in, local learned state is omitted from settings transfer, and the confirmed clear action removes persisted and in-memory ranking state.", + "coverage": [ + { + "layer": "unit", + "file": "tests/options.personalization.test.ts", + "test": "excludes learned words from settings export and import" + }, + { + "layer": "unit", + "file": "tests/PersonalizationService.test.ts", + "test": "clears persisted data and in-memory ranking immediately" + }, + { + "layer": "e2e-full", + "file": "tests/e2e/full.e2e.test.ts", + "test": "Personalized menu and inline ranking survives runtime restart and clears locally" + } + ] } ] } diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index 1effd565..96e097ef 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -3,10 +3,13 @@ import path from "path"; import * as fs from "fs"; import type { Server } from "http"; import { createServer } from "http"; +import { PERSONALIZATION_STORAGE_KEY } from "../../src/core/application/personalization/PersonalizationRepository"; import { CMD_OPTIONS_GET_PREDICTOR_DEBUG_SNAPSHOT, + CMD_OPTIONS_CLEAR_PERSONALIZATION, CMD_OPTIONS_PAGE_CONFIG_CHANGE, KEY_AI_PREDICTOR_ENABLED, + KEY_AUTOCOMPLETE_ON_TAB, KEY_AUTO_LANGUAGE_SITE_PRIORS, KEY_DEBUG_AI_PREDICTOR_ENABLED, KEY_DEBUG_PRESAGE_PREDICTOR_ENABLED, @@ -17,6 +20,8 @@ import { KEY_INLINE_SUGGESTION, KEY_NUM_SUGGESTIONS, KEY_MIN_WORD_LENGTH_TO_PREDICT, + KEY_PERSONALIZATION_ENABLED, + KEY_PREFIX_ONLY_MODE, KEY_PRODUCTIVITY_STATS, KEY_SITE_PROFILES, KEY_TEXT_EXPANSIONS, @@ -479,6 +484,104 @@ async function getSetting(worker: BackgroundContext, key: string): Promise( + worker: BackgroundContext, + storageKey: string, +): Promise { + return (await worker.evaluate( + (storageKeyInner) => + new Promise((resolve, reject) => { + chrome.storage.local.get(storageKeyInner, (result) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + const rawValue = (result as Record)[storageKeyInner]; + resolve(rawValue ? JSON.parse(rawValue) : undefined); + }); + }), + storageKey, + )) as T | undefined; +} + +async function sendRuntimeCommand( + browser: Browser, + worker: BackgroundContext, + command: string, +): Promise { + const extensionPage = await openExtensionPage(browser, worker, "options/options.html"); + try { + await extensionPage.evaluate((commandInner) => { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { command: commandInner, context: {} }, + (response: { ok?: boolean } | undefined) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error(`Runtime command ${commandInner} returned not ok`)); + return; + } + resolve(); + }, + ); + }); + }, command); + } finally { + if (!extensionPage.isClosed()) { + await extensionPage.close(); + } + } +} + +async function restartExtensionRuntime( + browser: Browser, + worker: BackgroundContext, +): Promise { + if (!isFirefox() && typeof worker.close === "function") { + const wakePage = await openExtensionPage(browser, worker, "options/options.html"); + try { + await worker.close(); + await sleep(100); + await wakePage.evaluate((command) => { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { command, context: {} }, + (response: { ok?: boolean } | undefined) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (!response?.ok) { + reject(new Error("Restart wake command returned not ok")); + return; + } + resolve(); + }, + ); + }); + }, CMD_OPTIONS_PAGE_CONFIG_CHANGE); + return await reacquireWorkerContext(browser, "restarted extension runtime"); + } finally { + await wakePage.close(); + } + } else { + try { + await worker.evaluate(() => { + chrome.runtime.reload(); + }); + } catch (error) { + if (!isRetriableWorkerError(error)) { + throw error; + } + } + } + await sleep(250); + return await reacquireWorkerContext(browser, "restarted extension runtime"); +} + async function waitForSettingMatch( worker: BackgroundContext, key: string, @@ -2325,6 +2428,169 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { browserTimeout(45000, 70000), ); + test( + "Personalized menu and inline ranking survives runtime restart and clears locally", + async () => { + type PersonalizationStoreSnapshot = { + languages?: Record>; + }; + + const waitForLearnedScore = async (minimumScore: number): Promise => { + return await waitUntil( + `personalization score for through >= ${minimumScore}`, + async () => { + const store = await getLocalStorageValue( + worker!, + PERSONALIZATION_STORAGE_KEY, + ); + const score = store?.languages?.en_US?.through?.score; + return typeof score === "number" && score >= minimumScore ? score : false; + }, + { timeoutMs: browserTimeout(5000, 10000), intervalMs: 50 }, + ); + }; + + const openReadyInput = async (): Promise => { + await gotoTestPage(page); + await page.bringToFront(); + await waitForInputReady(page, "#test-input"); + }; + + const acceptThroughFromMenu = async (minimumScore: number): Promise => { + await openReadyInput(); + await typeInInput(page, "#test-input", "th"); + const suggestions = await waitForVisibleSuggestionTexts(page); + const throughIndex = suggestions.map(normalizeSuggestionText).indexOf("through"); + expect(throughIndex).toBeGreaterThanOrEqual(0); + + for (let index = 0; index < throughIndex; index += 1) { + await page.keyboard.press("ArrowDown"); + } + await page.keyboard.press("Tab"); + await waitUntil( + "menu acceptance to insert through", + async () => + normalizeSuggestionText(await getInputContent(page, "#test-input")) === "through" + ? true + : false, + { timeoutMs: browserTimeout(4000, 10000), intervalMs: 50 }, + ); + await waitForLearnedScore(minimumScore); + }; + + const expectFirstMenuSuggestion = async (expected: string): Promise => { + await openReadyInput(); + await typeInInput(page, "#test-input", "th"); + const [firstSuggestion] = await waitForVisibleSuggestionTexts(page); + expect(normalizeSuggestionText(firstSuggestion ?? "")).toBe(expected); + }; + + const expectInlineThrough = async (): Promise => { + await openReadyInput(); + await typeInInput(page, "#test-input", "th"); + const suffix = await waitUntil( + "personalized inline suggestion", + async () => { + const text = await page.evaluate( + () => document.querySelector(".ft-suggestion-inline")?.textContent ?? "", + ); + return normalizeSuggestionText(text) === "rough" ? text : false; + }, + { timeoutMs: browserTimeout(5000, 10000), intervalMs: 50 }, + ); + expect(normalizeSuggestionText(suffix)).toBe("rough"); + }; + + try { + await sendRuntimeCommand(browser, worker!, CMD_OPTIONS_CLEAR_PERSONALIZATION); + await setSettingAndWait(worker!, KEY_PERSONALIZATION_ENABLED, true); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 10); + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_PREFIX_ONLY_MODE, false); + await setSettingAndWait(worker!, KEY_AUTOCOMPLETE_ON_TAB, true); + await setSettingAndWait(worker!, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, false); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await applyConfigChange(browser, worker!); + + // Three deliberate selections put the decayed score safely above the + // two-acceptance promotion threshold even on slower E2E machines. + await acceptThroughFromMenu(1); + await acceptThroughFromMenu(1.9); + await acceptThroughFromMenu(2.9); + + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 3); + await applyConfigChange(browser, worker!); + await expectFirstMenuSuggestion("through"); + + worker = await restartExtensionRuntime(browser, worker!); + await expectFirstMenuSuggestion("through"); + + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, true); + await applyConfigChange(browser, worker!); + await expectInlineThrough(); + await page.keyboard.press("Tab"); + await waitUntil( + "inline acceptance to insert through", + async () => + normalizeSuggestionText(await getInputContent(page, "#test-input")) === "through" + ? true + : false, + { timeoutMs: browserTimeout(4000, 10000), intervalMs: 50 }, + ); + await waitForLearnedScore(3.8); + await expectInlineThrough(); + + const optionsPage = await openOptionsPage(browser, worker!); + try { + await optionsPage.evaluate(() => { + window.confirm = () => true; + }); + await optionsPage.waitForSelector('input[type="button"][value="Clear learned words"]', { + timeout: browserTimeout(5000, 10000), + }); + await sleep(100); + await optionsPage.evaluate(() => { + const button = document.querySelector( + 'input[type="button"][value="Clear learned words"]', + ); + if (!button) { + throw new Error("Clear learned words button not found"); + } + button.click(); + }); + await waitUntil( + "personalization storage to clear", + async () => + (await getLocalStorageValue(worker!, PERSONALIZATION_STORAGE_KEY)) === undefined + ? true + : false, + { timeoutMs: browserTimeout(5000, 10000), intervalMs: 50 }, + ); + } finally { + await optionsPage.close(); + } + + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await applyConfigChange(browser, worker!); + await expectFirstMenuSuggestion("the"); + } finally { + await sendRuntimeCommand(browser, worker!, CMD_OPTIONS_CLEAR_PERSONALIZATION).catch( + () => undefined, + ); + await setSettingAndWait(worker!, KEY_PERSONALIZATION_ENABLED, false); + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_PREFIX_ONLY_MODE, false); + await setSettingAndWait(worker!, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, true); + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 5); + await applyConfigChange(browser, worker!); + } + }, + browserTimeout(120000, 180000), + ); + test( "CKEditor preserves paragraph break when accepting suggestion at line end", async () => { diff --git a/tests/options.page.test.ts b/tests/options.page.test.ts index 32ac76c2..69c899a6 100644 --- a/tests/options.page.test.ts +++ b/tests/options.page.test.ts @@ -35,6 +35,32 @@ describe("options page scripts", () => { ); }); + test("exposes opt-in personalization and a separate clear action", async () => { + const { manifest } = await import("../src/ui/options/settingsManifest.js"); + const personalization = manifest.settings.find( + (setting) => setting.name === "personalizationEnabled", + ); + const clearAction = manifest.settings.find( + (setting) => setting.name === "clearPersonalizationButton", + ); + + expect(personalization).toEqual( + expect.objectContaining({ + type: "checkbox", + default: false, + }), + ); + expect("label" in personalization! && personalization.label).toContain( + "Learn from accepted suggestions", + ); + expect(clearAction).toEqual( + expect.objectContaining({ + type: "button", + text: "Clear learned words", + }), + ); + }); + test("prioritizes activation flow over demo and support content on onboarding", () => { const onboardingHtmlPath = path.resolve(process.cwd(), "public/new_installation/index.html"); const html = fs.readFileSync(onboardingHtmlPath, "utf8"); diff --git a/tests/options.personalization.test.ts b/tests/options.personalization.test.ts new file mode 100644 index 00000000..538b1e96 --- /dev/null +++ b/tests/options.personalization.test.ts @@ -0,0 +1,25 @@ +import { + createSettingsExportSnapshot, + sanitizeSettingsImportSnapshot, +} from "../src/ui/options/settings"; +import { PERSONALIZATION_STORAGE_KEY } from "../src/core/application/personalization/PersonalizationRepository"; + +describe("options personalization privacy", () => { + test("excludes learned words from settings export and import", () => { + const source = { + "store.settings.language": JSON.stringify("en_US"), + [PERSONALIZATION_STORAGE_KEY]: JSON.stringify({ + version: 1, + languages: { en_US: { private: { display: "private", score: 2 } } }, + }), + }; + + expect(createSettingsExportSnapshot(source)).toEqual({ + "store.settings.language": JSON.stringify("en_US"), + }); + expect(sanitizeSettingsImportSnapshot(source)).toEqual({ + "store.settings.language": JSON.stringify("en_US"), + }); + expect(Object.hasOwn(source, PERSONALIZATION_STORAGE_KEY)).toBe(true); + }); +}); diff --git a/tests/optionsWorkspacePanels.test.ts b/tests/optionsWorkspacePanels.test.ts index d826e7eb..728cfb8d 100644 --- a/tests/optionsWorkspacePanels.test.ts +++ b/tests/optionsWorkspacePanels.test.ts @@ -19,6 +19,7 @@ import { KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, KEY_MIN_WORD_LENGTH_TO_PREDICT, KEY_NUM_SUGGESTIONS, + KEY_PERSONALIZATION_ENABLED, KEY_OBSERVABILITY_DEFAULT_LEVEL, KEY_OBSERVABILITY_ENABLED, KEY_OBSERVABILITY_MODULE_OVERRIDES, @@ -86,6 +87,7 @@ describe("options workspace panels", () => { [KEY_PREFER_NATIVE_AUTOCOMPLETE]: new MockPanelControl("Prefer native autocomplete"), [KEY_NUM_SUGGESTIONS]: new MockPanelControl("Number of suggestions"), [KEY_MIN_WORD_LENGTH_TO_PREDICT]: new MockPanelControl("Minimum characters"), + [KEY_PERSONALIZATION_ENABLED]: new MockPanelControl("Learn from accepted suggestions"), [KEY_AUTOCOMPLETE_ON_TAB]: new MockPanelControl("Accept on Tab"), [KEY_AUTOCOMPLETE_ON_ENTER]: new MockPanelControl("Accept on Enter"), [KEY_AUTOCOMPLETE]: new MockPanelControl("Accept on Space"), @@ -101,6 +103,7 @@ describe("options workspace panels", () => { createGroup(tab, "Prediction", [ registry[KEY_NUM_SUGGESTIONS] as unknown as MockPanelControl, registry[KEY_MIN_WORD_LENGTH_TO_PREDICT] as unknown as MockPanelControl, + registry[KEY_PERSONALIZATION_ENABLED] as unknown as MockPanelControl, ]); createGroup(tab, "Accept", [ registry[KEY_AUTOCOMPLETE_ON_TAB] as unknown as MockPanelControl, @@ -118,6 +121,7 @@ describe("options workspace panels", () => { expect(panelRoot.textContent).toContain("Enable FluentTyper"); expect(panelRoot.textContent).toContain("Prefer native autocomplete"); expect(panelRoot.textContent).toContain("Number of suggestions"); + expect(panelRoot.textContent).toContain("Learn from accepted suggestions"); expect(panelRoot.textContent).toContain("Inline suggestion"); expect(tab.querySelectorAll(".settings-group.is-empty-workspace-group")).toHaveLength(4); }); @@ -134,6 +138,7 @@ describe("options workspace panels", () => { resetProductivityStatsButton: new MockPanelControl("Reset stats"), importSettingButton: new MockPanelControl("Import settings"), exportSettingButton: new MockPanelControl("Export settings"), + clearPersonalizationButton: new MockPanelControl("Clear learned words"), } as unknown as SettingsRegistry; createGroup(tab, "Productivity", [ @@ -143,11 +148,13 @@ describe("options workspace panels", () => { createGroup(tab, "Config", [ registry.importSettingButton as unknown as MockPanelControl, registry.exportSettingButton as unknown as MockPanelControl, + registry.clearPersonalizationButton as unknown as MockPanelControl, ]); new DataDiagnosticsPanel(panelRoot, registry); expect(panelRoot.textContent).toContain("Productivity graph"); expect(panelRoot.textContent).toContain("Import settings"); + expect(panelRoot.textContent).toContain("Clear learned words"); expect(panelRoot.textContent).toContain(i18n.get("data_panel_transfer_copy")); expect(panelRoot.textContent).not.toContain("Debug dashboard"); expect( From 39b27056547a8b07c58ceaa19fa80e96b97d2c88 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Tue, 28 Jul 2026 14:26:17 +0200 Subject: [PATCH 6/6] fix: address personalization review feedback --- .../personalization/PersonalizationService.ts | 62 ++++++++++++++----- .../storage/ChromeStorageBackend.ts | 33 ++++++++-- .../personalization/PersonalizationPolicy.ts | 26 ++++++-- .../personalization/PersonalizationRanker.ts | 9 ++- src/core/domain/personalization/types.ts | 1 + tests/PersonalizationPolicy.test.ts | 26 +++++++- tests/PersonalizationService.test.ts | 46 +++++++++++++- tests/store.test.ts | 46 ++++++++++++-- 8 files changed, 212 insertions(+), 37 deletions(-) diff --git a/src/core/application/personalization/PersonalizationService.ts b/src/core/application/personalization/PersonalizationService.ts index 3ae5a5b8..33eee3a7 100644 --- a/src/core/application/personalization/PersonalizationService.ts +++ b/src/core/application/personalization/PersonalizationService.ts @@ -63,7 +63,7 @@ export class PersonalizationService { if ( !isValidEventId(event.eventId) || !(await this.safeIsEnabled()) || - this.store.recentEvents[event.eventId] + getOwnProperty(this.store.recentEvents, event.eventId) ) { return false; } @@ -75,19 +75,24 @@ export class PersonalizationService { const nowMs = this.now(); const next = cloneStore(this.store); - const languageWords = next.languages[event.language] ?? {}; - const current = languageWords[normalized.normalizedWord]; - languageWords[normalized.normalizedWord] = { + const languageWords = getOwnProperty(next.languages, event.language) ?? {}; + const current = getOwnProperty(languageWords, normalized.normalizedWord); + defineOwnProperty(languageWords, normalized.normalizedWord, { display: normalized.display, score: (current ? calculateEffectivePersonalizationScore(current, nowMs) : 0) + 1, updatedAtMs: nowMs, - }; - next.languages[event.language] = prunePersonalizationLanguage(languageWords, nowMs); - next.recentEvents[event.eventId] = { + }); + defineOwnProperty( + next.languages, + event.language, + prunePersonalizationLanguage(languageWords, nowMs), + ); + defineOwnProperty(next.recentEvents, event.eventId, { language: event.language, normalizedWord: normalized.normalizedWord, + acceptedAtMs: nowMs, applied: true, - }; + }); next.recentEvents = trimRecentEvents(next.recentEvents); await this.commit(next); return true; @@ -99,26 +104,36 @@ export class PersonalizationService { if (!isValidEventId(eventId)) { return false; } - const recentEvent = this.store.recentEvents[eventId]; + const recentEvent = getOwnProperty(this.store.recentEvents, eventId); if (!recentEvent?.applied) { return false; } const nowMs = this.now(); const next = cloneStore(this.store); - const nextEvent = next.recentEvents[eventId]; - const languageWords = next.languages[nextEvent.language]; - const word = languageWords?.[nextEvent.normalizedWord]; - if (word) { - const reversedScore = calculateEffectivePersonalizationScore(word, nowMs) - 1; + const nextEvent = getOwnProperty(next.recentEvents, eventId); + if (!nextEvent) { + return false; + } + const languageWords = getOwnProperty(next.languages, nextEvent.language); + const word = languageWords + ? getOwnProperty(languageWords, nextEvent.normalizedWord) + : undefined; + if (word && languageWords) { + const acceptedContribution = calculateEffectivePersonalizationScore( + { score: 1, updatedAtMs: nextEvent.acceptedAtMs }, + nowMs, + ); + const reversedScore = + calculateEffectivePersonalizationScore(word, nowMs) - acceptedContribution; if (reversedScore <= Number.EPSILON) { delete languageWords[nextEvent.normalizedWord]; } else { - languageWords[nextEvent.normalizedWord] = { + defineOwnProperty(languageWords, nextEvent.normalizedWord, { ...word, score: reversedScore, updatedAtMs: nowMs, - }; + }); } if (Object.keys(languageWords).length === 0) { delete next.languages[nextEvent.language]; @@ -133,8 +148,8 @@ export class PersonalizationService { async clear(): Promise { await this.serializeMutation(async () => { const empty = createEmptyPersonalizationStore(); - this.replaceInMemoryStore(empty); await this.repository.clear(); + this.replaceInMemoryStore(empty); }); } @@ -222,3 +237,16 @@ function createImmutableSnapshot(store: PersonalizationStoreV1): Personalization } return Object.freeze(languages) as PersonalizationRankingSnapshot; } + +function getOwnProperty(record: Record, key: string): T | undefined { + return Object.hasOwn(record, key) ? record[key] : undefined; +} + +function defineOwnProperty(record: Record, key: string, value: T): void { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} diff --git a/src/core/application/storage/ChromeStorageBackend.ts b/src/core/application/storage/ChromeStorageBackend.ts index e3326035..4b277249 100644 --- a/src/core/application/storage/ChromeStorageBackend.ts +++ b/src/core/application/storage/ChromeStorageBackend.ts @@ -7,6 +7,11 @@ function toError(error: unknown): Error { return new Error(typeof error === "string" ? error : String(error)); } +function getRuntimeError(): Error | null { + const lastError = chrome.runtime?.lastError; + return lastError ? new Error(lastError.message) : null; +} + export class ChromeStorageBackend implements StorageBackend { private readonly backend: chrome.storage.StorageArea; @@ -18,6 +23,11 @@ export class ChromeStorageBackend implements StorageBackend { return new Promise((resolve, reject) => { try { this.backend.get(key, (value) => { + const runtimeError = getRuntimeError(); + if (runtimeError) { + reject(runtimeError); + return; + } resolve(value[key] as string | undefined); }); } catch (ex) { @@ -30,9 +40,9 @@ export class ChromeStorageBackend implements StorageBackend { return new Promise((resolve, reject) => { try { this.backend.set({ [key]: value }, () => { - const lastError = chrome.runtime?.lastError; - if (lastError) { - reject(new Error(lastError.message)); + const runtimeError = getRuntimeError(); + if (runtimeError) { + reject(runtimeError); return; } resolve(); @@ -47,6 +57,11 @@ export class ChromeStorageBackend implements StorageBackend { return new Promise((resolve, reject) => { try { this.backend.remove(key, () => { + const runtimeError = getRuntimeError(); + if (runtimeError) { + reject(runtimeError); + return; + } resolve(); }); } catch (ex) { @@ -59,12 +74,22 @@ export class ChromeStorageBackend implements StorageBackend { return new Promise((resolve, reject) => { try { this.backend.get(null, (values) => { + const runtimeError = getRuntimeError(); + if (runtimeError) { + reject(runtimeError); + return; + } const result: Record = {}; for (const [key, value] of Object.entries(values)) { if (!key.startsWith(prefix)) { continue; } - result[key.substring(prefix.length)] = value as string; + Object.defineProperty(result, key.substring(prefix.length), { + configurable: true, + enumerable: true, + value: value as string, + writable: true, + }); } resolve(result); }); diff --git a/src/core/domain/personalization/PersonalizationPolicy.ts b/src/core/domain/personalization/PersonalizationPolicy.ts index 78f34801..6acc6be0 100644 --- a/src/core/domain/personalization/PersonalizationPolicy.ts +++ b/src/core/domain/personalization/PersonalizationPolicy.ts @@ -30,7 +30,7 @@ export function isPersonalizationLanguage(language: unknown): language is string typeof language === "string" && language !== "auto_detect" && language !== "textExpander" && - language in SUPPORTED_LANGUAGES + Object.hasOwn(SUPPORTED_LANGUAGES, language) ); } @@ -125,11 +125,11 @@ export function sanitizePersonalizationStore( ) { continue; } - words[normalized.normalizedWord] = { + defineOwnProperty(words, normalized.normalizedWord, { display: display.display, score: rawWord.score, updatedAtMs: rawWord.updatedAtMs, - }; + }); } const pruned = prunePersonalizationLanguage(words, nowMs); if (Object.keys(pruned).length > 0) { @@ -145,18 +145,23 @@ export function sanitizePersonalizationStore( continue; } const language = rawEvent.language; - if (!isPersonalizationLanguage(language) || typeof rawEvent.applied !== "boolean") { + if ( + !isPersonalizationLanguage(language) || + !isValidTimestamp(rawEvent.acceptedAtMs) || + typeof rawEvent.applied !== "boolean" + ) { continue; } const normalized = normalizePersonalizationWord(rawEvent.normalizedWord, language); if (!normalized || normalized.normalizedWord !== rawEvent.normalizedWord) { continue; } - recentEvents[eventId] = { + defineOwnProperty(recentEvents, eventId, { language, normalizedWord: normalized.normalizedWord, + acceptedAtMs: rawEvent.acceptedAtMs, applied: rawEvent.applied, - }; + }); } } @@ -193,3 +198,12 @@ function isPositiveFiniteNumber(value: unknown): value is number { function isValidTimestamp(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; } + +function defineOwnProperty(record: Record, key: string, value: T): void { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} diff --git a/src/core/domain/personalization/PersonalizationRanker.ts b/src/core/domain/personalization/PersonalizationRanker.ts index 1961cd14..8f791078 100644 --- a/src/core/domain/personalization/PersonalizationRanker.ts +++ b/src/core/domain/personalization/PersonalizationRanker.ts @@ -7,7 +7,9 @@ import type { RankedCandidateOptions } from "./types"; export function rankPersonalizedCandidates(options: RankedCandidateOptions): string[] { const candidates = options.candidates.slice(); - const languageSnapshot = options.snapshot[options.language]; + const languageSnapshot = Object.hasOwn(options.snapshot, options.language) + ? options.snapshot[options.language] + : undefined; if (!languageSnapshot || candidates.length < 2) { return candidates; } @@ -15,7 +17,10 @@ export function rankPersonalizedCandidates(options: RankedCandidateOptions): str const pinnedCandidates = options.pinnedCandidates ?? new Set(); const ranked = candidates.map((candidate, index) => { const normalized = normalizePersonalizationWord(candidate, options.language); - const learned = normalized ? languageSnapshot[normalized.normalizedWord] : undefined; + const learned = + normalized && Object.hasOwn(languageSnapshot, normalized.normalizedWord) + ? languageSnapshot[normalized.normalizedWord] + : undefined; const effectiveScore = learned ? calculateEffectivePersonalizationScore(learned, options.nowMs) : 0; diff --git a/src/core/domain/personalization/types.ts b/src/core/domain/personalization/types.ts index d050ac73..95adde40 100644 --- a/src/core/domain/personalization/types.ts +++ b/src/core/domain/personalization/types.ts @@ -7,6 +7,7 @@ export interface PersonalizationWord { export interface PersonalizationRecentEvent { language: string; normalizedWord: string; + acceptedAtMs: number; applied: boolean; } diff --git a/tests/PersonalizationPolicy.test.ts b/tests/PersonalizationPolicy.test.ts index c17396d8..fcd4040a 100644 --- a/tests/PersonalizationPolicy.test.ts +++ b/tests/PersonalizationPolicy.test.ts @@ -23,6 +23,11 @@ describe("PersonalizationPolicy", () => { }, ); + test("rejects inherited object keys as languages", () => { + expect(normalizePersonalizationWord("word", "constructor")).toBeNull(); + expect(normalizePersonalizationWord("word", "__proto__")).toBeNull(); + }); + test("decays scores deterministically", () => { const score = calculateEffectivePersonalizationScore( { score: 4, updatedAtMs: 1_000 }, @@ -61,8 +66,18 @@ describe("PersonalizationPolicy", () => { unknown: { word: { display: "word", score: 2, updatedAtMs: 100 } }, }, recentEvents: { - accepted: { language: "en_US", normalizedWord: "valid", applied: true }, - bad: { language: "unknown", normalizedWord: "word", applied: true }, + accepted: { + language: "en_US", + normalizedWord: "valid", + acceptedAtMs: 150, + applied: true, + }, + bad: { + language: "unknown", + normalizedWord: "word", + acceptedAtMs: 150, + applied: true, + }, }, }, 200, @@ -75,7 +90,12 @@ describe("PersonalizationPolicy", () => { }, }, recentEvents: { - accepted: { language: "en_US", normalizedWord: "valid", applied: true }, + accepted: { + language: "en_US", + normalizedWord: "valid", + acceptedAtMs: 150, + applied: true, + }, }, }); }); diff --git a/tests/PersonalizationService.test.ts b/tests/PersonalizationService.test.ts index a847b5dc..3f99015e 100644 --- a/tests/PersonalizationService.test.ts +++ b/tests/PersonalizationService.test.ts @@ -2,12 +2,14 @@ import { jest } from "bun:test"; import { PersonalizationRepository } from "../src/core/application/personalization/PersonalizationRepository"; import { PersonalizationService } from "../src/core/application/personalization/PersonalizationService"; import type { StorageBackend } from "../src/core/application/storage/StorageBackend"; +import { PERSONALIZATION_DECAY_WINDOW_MS } from "../src/core/domain/personalization/PersonalizationPolicy"; class CountingMemoryStorageBackend implements StorageBackend { values = new Map(); reads = 0; writes = 0; removes = 0; + removeError: Error | null = null; async get(key: string) { this.reads += 1; @@ -21,6 +23,9 @@ class CountingMemoryStorageBackend implements StorageBackend { async remove(key: string) { this.removes += 1; + if (this.removeError) { + throw this.removeError; + } this.values.delete(key); } @@ -110,13 +115,38 @@ describe("PersonalizationService", () => { now: () => nowMs, }); await service.accept(accepted("first")); + nowMs += PERSONALIZATION_DECAY_WINDOW_MS; await service.accept(accepted("second")); - nowMs += 10; expect(await service.revert("first")).toBe(true); expect(service.getRankingSnapshot().en_US.hello.score).toBeCloseTo(1, 5); }); + test("learns prototype-named words as own persisted entries", async () => { + const backend = new CountingMemoryStorageBackend(); + const repository = new PersonalizationRepository(backend); + const service = new PersonalizationService({ + repository, + isEnabled: () => true, + now: () => 1_000, + }); + + expect(await service.accept(accepted("constructor", "constructor"))).toBe(true); + expect(await service.accept(accepted("__proto__", "__proto__"))).toBe(true); + + const restarted = new PersonalizationService({ + repository, + isEnabled: () => true, + now: () => 1_000, + }); + await restarted.initialize(); + const words = restarted.getRankingSnapshot().en_US; + expect(Object.hasOwn(words, "constructor")).toBe(true); + expect(words.constructor.score).toBe(1); + expect(Object.hasOwn(words, "__proto__")).toBe(true); + expect(words.__proto__.score).toBe(1); + }); + test("disabled mode and text expansions do not learn or persist events", async () => { const backend = new CountingMemoryStorageBackend(); const isEnabled = jest.fn(() => false); @@ -155,6 +185,20 @@ describe("PersonalizationService", () => { expect(backend.removes).toBe(1); }); + test("preserves in-memory ranking when persisted data cannot be cleared", async () => { + const backend = new CountingMemoryStorageBackend(); + const service = new PersonalizationService({ + repository: new PersonalizationRepository(backend), + isEnabled: () => true, + now: () => 1_000, + }); + await service.accept(accepted("one")); + backend.removeError = new Error("remove denied"); + + await expect(service.clear()).rejects.toThrow("remove denied"); + expect(service.getRankingSnapshot().en_US.hello.score).toBe(1); + }); + test("repairs malformed persisted data without breaking initialization", async () => { const backend = new CountingMemoryStorageBackend(); backend.values.set( diff --git a/tests/store.test.ts b/tests/store.test.ts index 8719b2ab..04330690 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -5,6 +5,8 @@ type StorageSnapshot = Record; type ChromeStorageMockOptions = { initialState?: StorageSnapshot; setDelayMs?: number; + getError?: string; + removeError?: string; }; let importNonce = 0; @@ -30,8 +32,14 @@ function installChromeStorageMock(options: ChromeStorageMockOptions = {}): { (values: Record, callback?: (() => void) | undefined) => void >; } { - const { initialState = {}, setDelayMs = 0 } = options; + const { initialState = {}, setDelayMs = 0, getError, removeError } = options; const storageState: StorageSnapshot = { ...initialState }; + const runtime: { + getManifest: () => { version: string }; + lastError?: { message: string }; + } = { + getManifest: () => ({ version: "test-version" }), + }; const localSet = jest.fn( (values: Record, callback?: (() => void) | undefined): void => { setTimeout(() => { @@ -44,6 +52,12 @@ function installChromeStorageMock(options: ChromeStorageMockOptions = {}): { const localGet = jest.fn( (key: string | string[] | null, callback: (result: Record) => void): void => { setTimeout(() => { + if (getError) { + runtime.lastError = { message: getError }; + callback({}); + delete runtime.lastError; + return; + } if (typeof key === "string") { callback({ [key]: storageState[key] }); return; @@ -65,15 +79,19 @@ function installChromeStorageMock(options: ChromeStorageMockOptions = {}): { const localRemove = jest.fn((key: string, callback?: (() => void) | undefined): void => { setTimeout(() => { + if (removeError) { + runtime.lastError = { message: removeError }; + callback?.(); + delete runtime.lastError; + return; + } delete storageState[key]; callback?.(); }, 0); }); setGlobalProperty("chrome", { - runtime: { - getManifest: () => ({ version: "test-version" }), - }, + runtime, i18n: { getMessage: (key: string) => key, }, @@ -163,6 +181,26 @@ describe("ChromeStorageBackend.getAll", () => { }); }); +describe("ChromeStorageBackend failures", () => { + test("rejects a failed browser storage read", async () => { + installChromeStorageMock({ getError: "read denied" }); + const { ChromeStorageBackend } = await import( + freshModulePath("../src/core/application/storage/ChromeStorageBackend.js") + ); + + await expect(new ChromeStorageBackend(true).get("key")).rejects.toThrow("read denied"); + }); + + test("rejects a failed browser storage removal", async () => { + installChromeStorageMock({ removeError: "remove denied" }); + const { ChromeStorageBackend } = await import( + freshModulePath("../src/core/application/storage/ChromeStorageBackend.js") + ); + + await expect(new ChromeStorageBackend(true).remove("key")).rejects.toThrow("remove denied"); + }); +}); + describe("Store async semantics", () => { test("set resolves only after backend callback completes", async () => { const { localSet } = installChromeStorageMock({ setDelayMs: 25 });