Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/classmap-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
147 changes: 100 additions & 47 deletions scripts/classmap-cdp-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"),
Expand All @@ -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,
)
);
}

Expand Down
11 changes: 10 additions & 1 deletion scripts/classmap-cdp-verify.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
}
});
25 changes: 16 additions & 9 deletions scripts/classmap-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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" \
Expand Down
Loading