diff --git a/src/chatbots/Pi.ts b/src/chatbots/Pi.ts index 998d3a1799..baa5700d88 100644 --- a/src/chatbots/Pi.ts +++ b/src/chatbots/Pi.ts @@ -110,7 +110,17 @@ class PiAIChatbot extends AbstractChatbot { } getVoiceSettingsSelector(): string { - return "div.mx-auto.w-full.px-6.py-10 > div.grid.grid-cols-2.gap-4"; + // Pi's Voice settings page (pi.ai/profile/settings) — re-anchored 2026-07-04 + // (Layer-4 CDP): the content column drifted `px-6 py-10` → `px-6 py-3 pt-20` + // and the card grid `grid-cols-2` → `grid-cols-1 gap-4 sm:grid-cols-2`, so + // the old literal matched 0 and PiVoiceSettings never decorated (no door). + // Anchor on the settings content column (`mx-auto w-full max-w-2xl px-6` — + // `max-w-2xl` keeps this off wider surfaces like /discover /threads, which + // the every-batch body scan in bootstrap also visits) and its direct-child + // responsive card grid. Volatile padding / exact column-count literals are + // dropped. Verified live: matches the settings grid, 0 on /talk /discover + // /threads. + return 'div.mx-auto.w-full.max-w-2xl.px-6 > div.grid.gap-4[class~="sm:grid-cols-2"]'; } getChatHistory(searchRoot: HTMLElement): HTMLElement { diff --git a/src/chatbots/PiVoiceMenu.ts b/src/chatbots/PiVoiceMenu.ts index 7bbe7ac08a..5d0dfcf235 100644 --- a/src/chatbots/PiVoiceMenu.ts +++ b/src/chatbots/PiVoiceMenu.ts @@ -8,6 +8,53 @@ import { PI_MENU_CAP } from "../tts/VoiceCuration"; import { Chatbot } from "./Chatbot"; import { UserPreferenceModule } from "../prefs/PreferenceModule"; +/** + * Build a "More voices" door for Pi's live voice surfaces. It visually CLONES a + * Pi native row/card (`template`) by copying its class list — Pi's own utilities + * are already compiled, so its arbitrary classes (`min-h-11`, `h-[56px]`, + * `!bg-secondary-default`) apply to the clone; SayPi can't author those itself + * (host-injected-arbitrary-Tailwind). A REAL template is required (callers wait + * for Pi to populate its rows/cards) — cloning nothing would leave a permanently + * unstyled foreign door that the idempotence guard then never re-styles. `tag` + * matches the host's native element — Pi's in-chat rows are `
`s, its + * settings cards are ``).join("")} +
+ + `; + +// A chat/talk-page-ish DOM: audio controls, NO settings grid. +const talkHTML = ` +
+
+
+ + +
+
+
`; + +// A WIDER surface (e.g. /discover) that also renders a responsive card grid — +// the every-batch body scan visits it too, so the selector must NOT match it. +const widerGridHTML = ` +
+
+ + +
+
`; + +describe("Pi Voice settings selector (#491 sibling — settings-page redesign)", () => { + const chatbot = new PiAIChatbot(); + const q = (html: string, sel: string) => + new JSDOM(html).window.document.querySelectorAll(sel); + + it("the shipped-broken literal matches 0 on the live settings grid (documents the bug)", () => { + const old = "div.mx-auto.w-full.px-6.py-10 > div.grid.grid-cols-2.gap-4"; + expect(q(settingsHTML, old).length).toBe(0); + }); + + it("the new selector matches the settings card grid exactly once", () => { + const matches = q(settingsHTML, chatbot.getVoiceSettingsSelector()); + expect(matches.length).toBe(1); + expect((matches[0] as HTMLElement).classList.contains("grid")).toBe(true); + // It's the card grid — its children are the voice cards. + expect((matches[0] as HTMLElement).querySelectorAll("button").length).toBe(8); + }); + + it("does NOT match a chat/talk-page DOM (no spurious decoration — the body scan visits every chatable page)", () => { + expect(q(talkHTML, chatbot.getVoiceSettingsSelector()).length).toBe(0); + }); + + it("does NOT match a wider (max-w-4xl) card grid like /discover — max-w-2xl scopes it to the settings column", () => { + expect(q(widerGridHTML, chatbot.getVoiceSettingsSelector()).length).toBe(0); + }); +}); diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts index 2473e8ebf3..5eeecac71e 100644 --- a/test/chatbots/PiVoiceMenu-curation.spec.ts +++ b/test/chatbots/PiVoiceMenu-curation.spec.ts @@ -23,7 +23,7 @@ vi.mock("../../src/popup/popupopener", () => ({ openSettings: (...args: unknown[]) => openSettingsMock(...args), })); -import { PiVoiceMenu, PiVoiceSettings } from "../../src/chatbots/PiVoiceMenu"; +import { PiVoiceMenu } from "../../src/chatbots/PiVoiceMenu"; import { PI_MENU_CAP } from "../../src/tts/VoiceCuration"; import { ElevenLabsVoice, OpenAIVoice, openAiMockVoices } from "../data/Voices"; import { SpeechSynthesisVoiceRemote } from "../../src/tts/SpeechModel"; @@ -116,41 +116,9 @@ describe("PiVoiceMenu shortlist cap + door", () => { }); }); -describe("PiVoiceSettings door (#472)", () => { - function makeSettings(): any { - const settings = Object.create(PiVoiceSettings.prototype); - settings.chatbot = { getID: () => "pi" } as any; - settings.userPreferences = { - getVoice: vi.fn(async () => null), - setVoice: vi.fn(async () => {}), - unsetVoice: vi.fn(async () => {}), - }; - settings.element = document.createElement("div"); - settings.introduceVoice = vi.fn(); - return settings; - } - - it("appends a 'More voices' door to the uncapped settings grid", () => { - const settings = makeSettings(); - settings.renderMenu([...piElevenLabs], null); - expect(customRows(settings.element).length).toBe(piElevenLabs.length); // grid stays uncapped - const door = settings.element.querySelector( - "button.saypi-more-voices" - ) as HTMLButtonElement; - expect(door).not.toBeNull(); - door.click(); - expect(openSettingsMock).toHaveBeenCalledWith("voices"); - }); - - it("does not duplicate the door on repeated renders", () => { - const settings = makeSettings(); - settings.renderMenu([...piElevenLabs], null); - settings.renderMenu([...piElevenLabs], null); - expect( - settings.element.querySelectorAll("button.saypi-more-voices").length - ).toBe(1); - }); -}); +// PiVoiceSettings is now door-first (Pi's settings grid gets only the "More +// voices" door, not inline SayPi rows) — its coverage lives in +// test/chatbots/PiVoiceSettings-more-voices-door.spec.ts. describe("PiVoiceMenu tier badge", () => { it("suffixes premium rows with a quiet HD badge only when tiers coexist", () => { diff --git a/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts b/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts new file mode 100644 index 0000000000..9eab671bd3 --- /dev/null +++ b/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ConfigModule reads injected env at import time; stub it (mirrors the sibling specs). +vi.mock("../../src/ConfigModule", () => ({ + config: { + appServerUrl: "https://app.example.com", + apiServerUrl: "https://api.saypi.ai", + GA_MEASUREMENT_ID: "x", + GA_API_SECRET: "x", + GA_ENDPOINT: "x", + }, +})); + +vi.mock("../../src/JwtManager", () => ({ + getJwtManagerSync: () => ({ + isAuthenticated: () => true, + getClaims: () => ({ ttsQuotaRemaining: 1000 }), + }), +})); + +const openSettingsMock = vi.fn(); +vi.mock("../../src/popup/popupopener", () => ({ + openSettings: (...args: unknown[]) => openSettingsMock(...args), +})); + +import { PiVoiceSettings } from "../../src/chatbots/PiVoiceMenu"; + +/** + * A faithful fixture of pi.ai's CURRENT Voice settings grid (captured live via + * Layer-4 CDP, 2026-07-04 — see #491 follow-up). The page renders a `div.grid` + * of `