From 67725829f2607faf28cc64535e3355c727265f7d Mon Sep 17 00:00:00 2001 From: Ross Cadogan Date: Sat, 4 Jul 2026 23:44:47 +0100 Subject: [PATCH] fix(pi): add the "More voices" door to Pi's Voice settings page (#491 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The founder's #491 door-first fix (#496) restored the door in Pi's in-chat menu; the same door was missing from Pi's own Voice settings page (pi.ai/profile/settings). Two stale-DOM causes, both verified live (Layer-4 CDP, 2026-07-04): 1. getVoiceSettingsSelector matched 0 — Pi redesigned the page: the content column drifted `px-6 py-10` → `mx-auto w-full max-w-2xl px-6 py-3 pt-20` and the card grid `grid-cols-2` → `grid-cols-1 gap-4 sm:grid-cols-2`. Re-anchored to the settings content column (`max-w-2xl` keeps it off the wider /discover /threads surfaces) + its direct-child responsive card grid. 2. Even with a matching selector, decoration never fired: findAndDecorateVoiceSettings was gated behind sidebar-decoration in the same added node AND searched that node — but the grid is in the main content, and the settings route has no chat prompt, so the content-loaded chain never runs there. Un-gated it in the bootstrap to scan document.body once per mutation batch (idempotent via the getElementById guard; a no-op on ChatGPT (empty selector) and Claude (its stale Pi selector matches 0 there)). Door-first: PiVoiceSettings now injects only the "More voices" door (deferring inline SayPi voice rows, companion to the in-chat #497), cloning a native card's class list so it renders Pi-native (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 `