From 84cd208521673e04411d2880b49f9e51d79c4d30 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 13:51:05 +0200 Subject: [PATCH 1/2] fix(scripts): open the context menu, credits, embed and Settings in the deep CDP run Steps now wait for what they open. The menu opens on a main-view track row instead of a Your Library row, credits and embed open from it, Settings opens through the account menu because a pushed history entry doesn't render it, and native elements count as open dialogs. On stock 1.3.1 the deep run observes 32/36 leaves with 10/10 steps, up from 23/36 and 7/8. --- scripts/classmap-cdp-verify.mjs | 147 ++++++++++++++++++--------- scripts/classmap-cdp-verify.test.mjs | 11 +- 2 files changed, 110 insertions(+), 48 deletions(-) diff --git a/scripts/classmap-cdp-verify.mjs b/scripts/classmap-cdp-verify.mjs index ecd8046b98..8b168138c4 100644 --- a/scripts/classmap-cdp-verify.mjs +++ b/scripts/classmap-cdp-verify.mjs @@ -368,6 +368,42 @@ const NAV_HELPERS = ` return "nav-failed:" + pathname; } }; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (probe, ms = 3000) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const value = probe(); + if (value) return value; + await wait(100); + } + return null; + }; + const openMenu = () => $$('[role="menu"]').find(visible); + const menuItem = (pattern) => + $$('[role="menu"] [role="menuitem"], [role="menu"] button, [role="menu"] a').find((el) => visible(el) && pattern.test((el.textContent || "").trim())); + const openDialogs = () => $$('dialog[open], [role="dialog"]').filter(visible); + // Closes open dialogs and menus so the next step starts from the page itself. + const closeOverlays = async () => { + for (const dialog of openDialogs()) { + const close = [...dialog.querySelectorAll("button[aria-label]")].find((b) => /^close$/i.test(b.getAttribute("aria-label"))); + if (close) click(close); + else dialog.close?.(); + } + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true })); + await waitFor(() => !openDialogs().length && !openMenu(), 1500); + }; + // Opens the context menu of the first visible track row. + const openTrackMenu = async () => { + // Track rows first: the Your Library sidebar also uses role="row". + const row = ['main [data-testid="tracklist-row"]', '[data-testid="tracklist-row"]', 'main [data-testid="internal-tracklist-row"]'] + .map((selector) => $$(selector).find(visible)) + .find(Boolean); + if (!row) return null; + const rect = row.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, view: window, clientX: rect.left + Math.min(40, rect.width / 2), clientY: rect.top + rect.height / 2, button: 2, buttons: 2 }; + row.dispatchEvent(new MouseEvent("contextmenu", opts)); + return waitFor(openMenu, 2000); + }; `; const NAV_STEPS = [ @@ -441,36 +477,47 @@ const NAV_STEPS = [ }, { name: "context_menu", - waitMs: 900, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Right-click a track row / more button to open context menu - const row = - $$('[data-testid="tracklist-row"], [data-testid="internal-tracklist-row"], [role="row"]').find(visible) || - $$('div[aria-selected], [data-testid*="track"]').find(visible); - if (row) { - const rect = row.getBoundingClientRect(); - const x = rect.left + Math.min(40, rect.width / 2); - const y = rect.top + rect.height / 2; - const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 2, buttons: 2 }; - row.dispatchEvent(new MouseEvent("contextmenu", opts)); - // also try the "more" button in the row - const more = row.querySelector('button[aria-label*="More" i], button[data-testid="more-button"]'); - if (more) click(more); - const menu = $$('[data-testid="context-menu"], [role="menu"], #context-menu, .main-contextMenu-menu').find(visible); - return menu ? "context-menu-open" : "context-menu-dispatched"; - } - // Fallback: more button anywhere in main view - const more = findClickable([(e) => /more options|more/i.test(textOf(e)) && e.tagName === "BUTTON"]); - if (click(more)) return "clicked-more"; - return "no-track-row"; + if (await openTrackMenu()) return "context-menu-open"; + return $$('[data-testid="tracklist-row"], [role="row"]').find(visible) ? "context-menu-dispatched" : "no-track-row"; + })()`, + }, + { + // Menu labels are matched in English, the language the verifier expects the client to use. + name: "track_credits", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + if (!openMenu() && !(await openTrackMenu())) return "no-track-row"; + const item = menuItem(/^View credits$/); + if (!click(item)) return "credits-not-found"; + return (await waitFor(() => openDialogs().length, 3000)) ? "credits-open" : "credits-not-found"; + })()`, + }, + { + name: "embed", + waitMs: 300, + expr: `(async () => { + ${NAV_HELPERS} + await closeOverlays(); + if (!(await openTrackMenu())) return "no-track-row"; + const share = menuItem(/^Share$/); + if (!share) return "embed-not-found"; + share.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + click(share); + const embed = await waitFor(() => menuItem(/^Embed (track|song|episode)$/), 2000); + if (!click(embed)) return "embed-not-found"; + return (await waitFor(() => openDialogs().length, 3000)) ? "embed-open" : "embed-not-found"; })()`, }, { name: "sort_or_filter", waitMs: 800, - expr: `(() => { + expr: `(async () => { ${NAV_HELPERS} + await closeOverlays(); const el = findClickable([ (e) => /sort|filter|custom order|recently|title|artist/i.test(textOf(e)), (e) => (e.getAttribute("data-testid") || "").includes("sort"), @@ -481,43 +528,49 @@ const NAV_STEPS = [ }, { name: "settings", - waitMs: 1500, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Profile chip → Settings - const profile = findClickable([ - (e) => (e.getAttribute("data-testid") || "") === "user-widget-link", - (e) => /profile|account menu/i.test(textOf(e)), - ]); - if (profile) click(profile); - const pref = findClickable([ - (e) => (e.getAttribute("href") || "").includes("/preferences"), - (e) => /settings|preferences/i.test(textOf(e)), - ]); - if (click(pref)) return "clicked-settings"; + await closeOverlays(); + // Settings opens from the account menu; a pushed history entry doesn't render it. + const profile = findClickable([(e) => (e.getAttribute("data-testid") || "") === "user-widget-link"]); + if (click(profile)) { + const item = await waitFor(() => menuItem(/^Settings$/), 2000); + if (click(item)) { + const rendered = await waitFor(() => $$("main input, main select").length > 5, 5000); + return rendered ? "settings-open" : "settings-not-rendered"; + } + } return navigateSpa("/preferences"); })()`, }, { name: "settings_scroll", - waitMs: 600, - expr: `(() => { + waitMs: 300, + expr: `(async () => { ${NAV_HELPERS} - // Scroll settings main to mount more sections - const main = document.querySelector('main, [data-testid="preferences-page"], [class*="settings"]') || document.scrollingElement; - if (main) { - main.scrollTop = main.scrollHeight / 2; - window.scrollTo(0, document.body.scrollHeight / 2); - return "scrolled-settings"; + // Scroll the whole settings page so lazily mounted sections render. + const inputs = $$("main input, main select"); + let scroller = inputs[0]; + while (scroller && !(scroller.scrollHeight > scroller.clientHeight + 50 && /auto|scroll/.test(getComputedStyle(scroller).overflowY))) { + scroller = scroller.parentElement; + } + if (!scroller) return "no-settings-scroll-target"; + for (let y = 0; y <= scroller.scrollHeight; y += 500) { + scroller.scrollTop = y; + await wait(120); } - return "no-settings-scroll-target"; + return "scrolled-settings"; })()`, }, ]; -function navigationSucceeded(result) { +export function navigationSucceeded(result) { return ( - typeof result === "string" && !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target)(?::|$)/.test(result) + typeof result === "string" && + !/^(?:nav-failed|context-menu-dispatched|no-track-row|sort-not-found|no-settings-scroll-target|credits-not-found|embed-not-found|settings-not-rendered)(?::|$)/.test( + result, + ) ); } diff --git a/scripts/classmap-cdp-verify.test.mjs b/scripts/classmap-cdp-verify.test.mjs index c133104832..11fd44d5cc 100644 --- a/scripts/classmap-cdp-verify.test.mjs +++ b/scripts/classmap-cdp-verify.test.mjs @@ -6,7 +6,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { loadChecks } from "./classmap-cdp-verify.mjs"; +import { loadChecks, navigationSucceeded } from "./classmap-cdp-verify.mjs"; const source = readFileSync(new URL("./classmap-cdp-verify.mjs", import.meta.url), "utf8"); const verifier = fileURLToPath(new URL("./classmap-cdp-verify.mjs", import.meta.url)); @@ -94,3 +94,12 @@ test("an overlay renames classes on top of the CLI css-map", () => { rmSync(dir, { recursive: true, force: true }); } }); + +test("a step that could not open its surface does not count as navigation", () => { + for (const result of ["context-menu-open", "credits-open", "embed-open", "settings-open", "scrolled-settings", "clicked-home"]) { + assert.equal(navigationSucceeded(result), true, result); + } + for (const result of ["context-menu-dispatched", "credits-not-found", "embed-not-found", "settings-not-rendered", "no-track-row", "nav-failed:/"]) { + assert.equal(navigationSucceeded(result), false, result); + } +}); From 3f29bccbb1dc5d39619bf1c621e7837d2709df9a Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 13:51:05 +0200 Subject: [PATCH 2/2] fix(scripts): default the e2e pipeline to the newest classmap key The previous defaults migrated from 1020040, which spicetify/classmaps no longer publishes, and wrote into the CLI checkout. The base now defaults to the newest key, BASE_SPA or BASE_CSS_DIR is required to migrate, and output goes to a temporary directory. --- scripts/classmap-capture.ts | 4 ++-- scripts/classmap-e2e.sh | 25 ++++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/classmap-capture.ts b/scripts/classmap-capture.ts index 65a9adc116..fb420491b4 100644 --- a/scripts/classmap-capture.ts +++ b/scripts/classmap-capture.ts @@ -24,8 +24,8 @@ * Examples * -------- * node scripts/classmap-capture.ts migrate \ - * --base-classmap ../classmaps/1020040/classmap.json \ - * --base-css-dir ../xpui-archive/1.2.40.599 \ + * --base-classmap ../classmaps/1030001/classmap.json \ + * --base-spa /path/to/1.3.1/xpui.spa \ * --target-spa "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" \ * --css-map css-map.json \ * --out classmaps/1020092/classmap.json \ diff --git a/scripts/classmap-e2e.sh b/scripts/classmap-e2e.sh index 84475b905d..1a4e0e6617 100755 --- a/scripts/classmap-e2e.sh +++ b/scripts/classmap-e2e.sh @@ -9,9 +9,10 @@ # Env: # SPOTIFY_SPA Path to xpui.spa (default: macOS Spotify.app) # SPOTIFY_VERSION Exact Spotify version recorded in the static report -# BASE_CLASSMAP Base classmap JSON (default: ../classmaps/1020040/...) -# BASE_CSS_DIR Base CSS dir from xpui-archive -# OUT_DIR Output dir (default: classmaps/1020092) +# BASE_CLASSMAP Base classmap JSON (default: the newest key in ../classmaps) +# BASE_SPA Stock xpui.spa of the base build (or BASE_CSS_DIR); required to migrate +# BASE_CSS_DIR Directory of the base build's stock CSS +# OUT_DIR Output dir (default: $TMPDIR/spicetify-classmap-e2e) # CDP_PORT Remote debugging port (default: 9222) # MIN_HIT_RATE CDP pass threshold (default: 0.25) # SPICETIFY Spicetify binary (default: ~/.spicetify/spicetify) @@ -75,9 +76,11 @@ STOCK_SPA_CANDIDATES=( "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa" "/Applications/Spotify.app/Contents/Resources/Apps/xpui.spa.bak" ) -BASE_CLASSMAP="${BASE_CLASSMAP:-$ROOT/../classmaps/1020040/classmap.json}" -BASE_CSS_DIR="${BASE_CSS_DIR:-$ROOT/../xpui-archive/1.2.40.599}" -OUT_DIR="${OUT_DIR:-$ROOT/classmaps/1020092}" +NEWEST_KEY="$(ls -d "$ROOT"/../classmaps/[0-9][0-9][0-9][0-9][0-9][0-9][0-9] 2>/dev/null | sort | tail -1)" +BASE_CLASSMAP="${BASE_CLASSMAP:-$NEWEST_KEY/classmap.json}" +BASE_SPA="${BASE_SPA:-}" +BASE_CSS_DIR="${BASE_CSS_DIR:-}" +OUT_DIR="${OUT_DIR:-${TMPDIR:-/tmp}/spicetify-classmap-e2e}" CSS_MAP="${CSS_MAP:-$ROOT/css-map.json}" CDP_PORT="${CDP_PORT:-9222}" SPICETIFY="${SPICETIFY:-$HOME/.spicetify/spicetify}" @@ -246,14 +249,18 @@ if [[ "$SKIP_MIGRATE" -eq 0 ]]; then echo "Missing base classmap: $BASE_CLASSMAP" >&2 exit 1 fi - if [[ ! -d "$BASE_CSS_DIR" ]]; then - echo "Missing base CSS dir: $BASE_CSS_DIR" >&2 + if [[ -n "$BASE_SPA" && -f "$BASE_SPA" ]]; then + BASE_ARGS=(--base-spa "$BASE_SPA") + elif [[ -n "$BASE_CSS_DIR" && -d "$BASE_CSS_DIR" ]]; then + BASE_ARGS=(--base-css-dir "$BASE_CSS_DIR") + else + echo "Set BASE_SPA to the stock xpui.spa of the base build, or BASE_CSS_DIR to its CSS" >&2 exit 1 fi resolve_target_css_args 1 "$NODE" scripts/classmap-capture.ts migrate \ --base-classmap "$BASE_CLASSMAP" \ - --base-css-dir "$BASE_CSS_DIR" \ + "${BASE_ARGS[@]}" \ "${TARGET_ARGS[@]}" \ --css-map "$CSS_MAP" \ --out "$CLASSMAP_OUT" \