android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest

The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
This commit is contained in:
KeelrLeek committed 2026-09-13 19:56:20 -05:00
1 parent 6e39dc43f3
commit 26928b706d
21 files changed
+1294 -92

No files matched your search

+9 -2
View File
@@ -54,10 +54,17 @@ export class CDP {
throw new Error("could not attach to the page on port " + port
+ (phone ? " (a WebView in the background does not answer; is SoulStream on screen?)" : ""));
}
send(method, params = {}) {
/* a page that never answers (frozen, or replaced by a reload) must fail
the call, not the whole walk */
send(method, params = {}, timeoutMs = 45000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((res) => this.waits.set(id, res));
return new Promise((res, rej) => {
const t = setTimeout(() => {
if (this.waits.delete(id)) rej(new Error("the page did not answer " + method + " in " + Math.round(timeoutMs / 1000) + "s — a frozen or replaced WebView?"));
}, timeoutMs);
this.waits.set(id, (m) => { clearTimeout(t); res(m); });
});
}
async evaluate(expr) {
const r = await this.send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true });
+226 -11
View File
@@ -54,6 +54,11 @@ window.__settle = function () {
if (s) { var c = s.querySelector('.np-head .iconbtn'); if (c) c.click(); }
var h = document.querySelector('.halfsheet.up');
if (h) { var sc = document.querySelector('.scrim'); if (sc) sc.click(); }
/* every tab back to its root, through the nav's own door (the history mirror stays true) */
if (window.__ss && __ss.go && __ss.nav) {
var tabs = Object.keys(__ss.nav.stacks);
for (var i = 0; i < tabs.length; i++) { if (__ss.nav.stacks[tabs[i]].length > 1) { __ss.go(tabs[i]); __ss.go(tabs[i]); } }
}
var home = document.querySelectorAll('.nav button')[0];
if (home) { home.click(); home.click(); }
return true;
@@ -114,6 +119,20 @@ const PROBE = `(function () {
impBtn: (document.querySelector('.imp-foot .btn-fill') || {}).textContent || '',
keepOn: !!document.querySelector('.imp-form .sw.on'),
bulkline: (document.querySelector('.bulkline') || {}).textContent || '',
brwDirs: document.querySelectorAll('.brw-dir').length,
qrows: document.querySelectorAll('.q-face .row').length,
viewer: !!document.querySelector('.viewer img'),
lyrLines: document.querySelectorAll('.lyr .ln').length,
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
recents: document.querySelectorAll('.recent-chips .chip').length,
dym: (document.querySelector('.dym') || {}).textContent || '',
sq: (document.querySelector('.sfield input') || {}).value || '',
slots: (document.querySelector('.slots .stepper .n') || {}).textContent || '',
wordfill: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Word-by-word/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
artEdge: (function () { var ps = document.querySelectorAll('.prefs[data-mode="albumart"] .pref'); var c = ps[1] && ps[1].querySelector('.chip.on'); return c ? c.textContent.trim() : ''; })(),
brwFiles: document.querySelectorAll('.brw-dir .peer-file').length,
brwHead: (document.querySelector('.brw-head .m') || {}).textContent || '',
keep: (function () { var g = document.querySelector('.prefs[data-mode="streamcache"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
impErr: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /didn't import/.test(x.textContent); }),
srcMax: (function () { var g = document.querySelector('.prefs[data-mode="stream"]'); var c = g && g.querySelector('.pref .chip.on'); return c ? c.textContent.trim() : ''; })(),
rowArt: document.querySelectorAll('.screen .row .art img').length,
@@ -132,9 +151,12 @@ const MOCK_STEPS = [
{ name: "home", wait: 1600 },
/* a long press on a card is its sheet */
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.halfsheet .row', 'Re-fetch the cover')`, name: "cover-refetched", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, name: "search-empty", wait: 700 },
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpo'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 900 },
/* a misspelt library name: the catalog has nothing, the library's own names correct it */
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 },
{ do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
@@ -144,14 +166,35 @@ const MOCK_STEPS = [
{ do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1100 },
/* the art opens the viewer; the back gesture closes it */
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 900 },
{ do: `history.back()`, name: "viewer-closed", wait: 700 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
/* a queue row's sheet: the row removed */
{ do: `document.querySelector('.q-face .row').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "queue-row-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'Remove from queue')`, name: "queue-removed", wait: 600 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
/* the lyrics tools: nudged half a second, another candidate chosen */
{ do: `__click('.lyr-tools', null)`, name: "lyrics-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Lyrics later')`, name: "lyrics-nudged", wait: 500 },
{ do: `__click('.halfsheet .row', 'Search again')`, name: "lyrics-candidates", wait: 900 },
{ do: `__click('.halfsheet .row', 'Live')`, name: "lyrics-chosen", wait: 700 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 },
{ do: `history.back()`, name: "after-back", wait: 900 },
{ do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 },
{ do: `history.back()`, wait: 700 },
{ do: `history.back()`, name: "library-again", wait: 800 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 800 },
/* a buddy's sheet: browse, message, remove */
{ do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 },
{ do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a user looked up by name: the peer sheet */
{ do: `__click('.screen .row', 'Look up a user')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'dusty_crates'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Open')`, name: "user-lookup", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.segtabs button', 'Rooms')`, wait: 500 },
/* the fake network: a room with a few lines in it */
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
{ do: `history.back()`, wait: 700 },
@@ -161,6 +204,11 @@ const MOCK_STEPS = [
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'daft punk discovery'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "cat-results", wait: 1400 },
/* the hero's artist: the desktop's page — tabs, Popular's bars, Follow,
the latest release, fans also like — then a fan-also-likes page */
{ do: `document.querySelector('.cat-song').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-song-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
/* a Records-rail card's long press: the record's sheet */
{ do: `(function () { var sec = [].slice.call(document.querySelectorAll('.sec')).find(function (x) { return /^\\s*Records/.test(x.textContent); }); var rail = sec && sec.nextElementSibling; rail.querySelector('.release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, name: "cat-record-sheet", wait: 600 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 },
{ do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 },
{ do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 },
@@ -170,12 +218,31 @@ const MOCK_STEPS = [
{ do: `document.querySelector('.cat-song').click()`, name: "net-playing", wait: 5500 },
{ do: `document.querySelector('.mini').click()`, name: "net-sheet", wait: 3000 },
{ name: "net-arrived", wait: 7000 },
/* the now-playing sheet's ⋯: the streamed track saved into the library
(its cache is complete by now, so it files at once) */
{ do: `document.querySelectorAll('.sheet .np-head .iconbtn')[1].click()`, name: "np-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "np-saved", wait: 900 },
/* the raw peers: a peer's sheet → their shares browsed → a folder opened
→ one of its files streams */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.net-ask', null)`, name: "net-peers", wait: 1800 },
{ do: `document.querySelector('.peer-file').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "peer-file-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelector('.peer-head .peer-menu').click()`, name: "peer-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Browse their files')`, name: "browse", wait: 1400 },
{ do: `__click('.brw-dir .peer-folder', 'Homework')`, name: "browse-dir", wait: 600 },
{ do: `__click('.brw-dir .peer-file', 'Aerodynamic')`, name: "browse-play", wait: 2500 },
{ do: `history.back()`, wait: 700 },
/* a catalog record: a row plays through the resolver with the rest of
the record queued behind it, the next track finds its own copy on
its turn, and the disc plays the whole record from one folder */
{ do: `__settle()`, wait: 800 },
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 900 },
{ do: `__click('.rail .release', 'Discovery')`, name: "cat-album", wait: 1500 },
/* the record page's own sheet from the bar */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "cat-album-sheet", wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.tracks .trk')[1].click()`, name: "cat-album-row", wait: 5500 },
{ do: `__ss.next()`, name: "cat-album-next", wait: 7000 },
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "cat-album-disc", wait: 6500 },
@@ -216,6 +283,11 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Download all tracks')`, name: "playlist-bulk-download", wait: 1500 },
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
/* the repair: the unknown row finds a library track and takes its identity */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "playlist-unknown-sheet", wait: 700 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, name: "playlist-match", wait: 500 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'caribou track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-match-results", wait: 700 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Track 4')`, name: "playlist-matched", wait: 1000 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
@@ -263,6 +335,23 @@ const MOCK_STEPS = [
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 500 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, wait: 400 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 week')`, name: "you-keep", wait: 500 },
{ do: `__click('.prefs[data-mode="streamcache"] .chip', '1 month')`, wait: 400 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 500 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, wait: 400 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 500 },
{ do: `__click('.screen .row', 'Word-by-word')`, wait: 400 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 500 },
/* the tab again pops its stack to the root */
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
{ do: `__click('.sfield .iconbtn', null)`, name: "search-recent", wait: 600 },
{ do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1400 },
{ do: `__settle()`, wait: 600 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-again", wait: 700 },
/* the page overflow → the sleep timer */
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
@@ -272,24 +361,48 @@ const MOCK_STEPS = [
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
community: (i) => i.title.startsWith("Community"),
buddies: (i) => i.title.startsWith("Community"),
"buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText),
"user-lookup": (i) => i.half.includes("up") && /dusty_crates/.test(i.halfText) && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
"home-more": (i) => i.half.includes("up"),
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
"you-norm-off": (i) => i.norm === "off",
"you-norm-on": (i) => i.norm === "on",
"you-lossy": (i) => i.srcMax === "Lossy only",
"you-keep": (i) => i.keep === "1 week",
"you-slots": (i) => i.slots === "3",
"you-words": (i) => i.wordfill === "on",
"you-artedge": (i) => i.artEdge === "1000 px",
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1,
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
"sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"),
home: (i) => /^(Good |Still)/.test(i.title),
"hold-sheet": (i) => i.half.includes("up"),
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
"search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library"),
album: (i) => i.screen.includes("tr-push") && !i.mini,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => /\+0\.5 s/.test(i.sheetTitle),
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
"track-sheet": (i) => i.half.includes("up"),
sheet: (i) => i.sheet.includes("up"),
"after-back": (i) => !i.sheet.includes("up"),
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img"),
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
"library-again": (i) => i.screen.includes("tr-pop"),
/* after the follow: the feed's lead ("Coming · ALBUM" — the upcoming
record leads), the news, the back catalogue, recommendations,
@@ -299,6 +412,8 @@ const MOCK_EXPECT = {
/* the artist with a picture shows it on the hero and its card; the
namesake under Deezer's empty-hash placeholder shows the app's own */
"cat-results": (i) => i.songs >= 4 && i.top.startsWith("Daft Punk") && i.heroImg && i.cardImgs === 1 && i.cardPhs === 1,
"cat-song-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to record/.test(i.halfText),
"cat-record-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Go to record/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2,
"cat-artist-follow": (i) => i.fb.includes("Following"),
"cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2,
@@ -306,7 +421,16 @@ const MOCK_EXPECT = {
"net-playing": (i) => i.mini && i.playing.includes("One More Time") && /streaming from|copy/.test(i.live),
"net-sheet": (i) => i.sheet.includes("up") && i.lane.includes("rising"),
"net-arrived": (i) => i.sheet.includes("up") && !i.lane.includes("rising"),
"np-more": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"np-saved": (i) => !i.half.includes("up") && /Saved to the library/.test(i.notice),
"net-peers": (i) => i.title.startsWith("Search") && i.peers >= 3,
"peer-file-sheet": (i) => i.half.includes("up") && /Save this file/.test(i.halfText) && /Play from here/.test(i.halfText),
"peer-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Play this folder/.test(i.halfText),
browse: (i) => i.screen.includes("tr-push") && i.brwDirs >= 2 && /folders/.test(i.brwHead),
"browse-dir": (i) => i.brwFiles >= 8,
"browse-play": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
"cat-album-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText) && /Add to playlist/.test(i.halfText) && /Go to artist/.test(i.halfText),
"cat-track-sheet": (i) => i.half.includes("up") && /Save to library/.test(i.halfText),
"cat-track-save": (i) => /Saving from /.test(i.halfText) || !i.half.includes("up"),
"cat-album-save": (i) => /^Saving \d+ from /.test(i.save),
@@ -324,6 +448,10 @@ const MOCK_EXPECT = {
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
"playlist-bulk-cached": (i) => /1 saved to library/.test(i.bulkline) && !i.half.includes("up"),
"playlist-bulk-download": (i) => /1 saved to library/.test(i.bulkline),
"playlist-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"playlist-match": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText) && !/Remove from playlist/.test(i.halfText),
"playlist-match-results": (i) => /Track 4/.test(i.halfText) && /Caribou/.test(i.halfText),
"playlist-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice),
"playlist-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
@@ -366,8 +494,14 @@ const PHONE_STEPS = [
{ do: `document.querySelector('.gbar .iconbtn').click()`, name: "library-ambience", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-ambience", wait: 1200 },
{ do: `document.querySelector('.mini').click()`, name: "sheet", wait: 1400 },
{ do: `document.querySelector('.np-art').click()`, name: "viewer", wait: 1200 },
{ do: `history.back()`, name: "viewer-closed", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()`, wait: 700 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 },
{ do: `history.back()`, name: "after-back", wait: 1000 },
/* a reload keeps the audio thread: the session comes back LIVE */
@@ -403,9 +537,14 @@ const PHONE_STEPS = [
in the library, reviewed, created, deleted again */
{ do: `__click('.newpl', 'Import')`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 },
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 },
/* the unknown row repaired from the real library */
{ do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 },
{ do: `__click('.halfsheet .row', 'Find a local match')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.plmatch input'); i.value = 'aerodynamic'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "import-match-results", wait: 1200 },
{ do: `__click('.halfsheet .sheet-rows .row', 'Aerodynamic')`, name: "import-matched", wait: 1500 },
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 800 },
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "import-deleted", wait: 1500 },
@@ -422,6 +561,12 @@ const PHONE_STEPS = [
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-on", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'Lossy only')`, name: "you-lossy", wait: 900 },
{ do: `__click('.prefs[data-mode="stream"] .chip', 'No limit')`, name: "you-nolimit", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn:last-child').click()`, name: "you-slots", wait: 900 },
{ do: `document.querySelector('.slots .stepper .iconbtn').click()`, name: "you-slots-back", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words", wait: 900 },
{ do: `__click('.screen .row', 'Word-by-word')`, name: "you-words-off", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 900 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
@@ -438,6 +583,10 @@ const PHONE_STEPS = [
const PHONE_EXPECT = {
album: (i) => i.screen.includes("tr-push") && i.amb.includes("on:img"),
"album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),
sheet: (i) => i.sheet.includes("up"),
"after-back": (i) => !i.sheet.includes("up"),
@@ -451,6 +600,12 @@ const PHONE_EXPECT = {
"you-norm-on": (i) => i.norm === "on" && !i.toast,
"you-lossy": (i) => i.srcMax === "Lossy only" && !i.toast,
"you-nolimit": (i) => i.srcMax === "No limit" && !i.toast,
"you-slots": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-slots-back": (i) => /^\d+$/.test(i.slots) && !i.toast,
"you-words": (i) => i.wordfill === "on" && !i.toast,
"you-words-off": (i) => i.wordfill === "off" && !i.toast,
"you-artedge": (i) => i.artEdge === "1000 px" && !i.toast,
"you-artedge-back": (i) => i.artEdge === "1500 px" && !i.toast,
playlists: (i) => i.title.startsWith("Your library"),
"playlist-new": (i) => i.screen.includes("tr-push") && i.title.startsWith("Walk test") && i.plrows === 0 && !i.toast,
"playlist-pick": (i) => i.half.includes("up") && /Walk test/.test(i.halfText),
@@ -459,8 +614,11 @@ const PHONE_EXPECT = {
"playlist-playing": (i) => i.mini && !!i.playing && !i.toast,
"playlist-more": (i) => i.half.includes("up") && /Delete playlist/.test(i.halfText),
"playlist-deleted": (i) => i.title.startsWith("Your library") && !/Walk test/.test(i.plnames) && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 2 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2 && !i.toast,
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && !i.toast,
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 3 && !i.toast,
"import-unknown-sheet": (i) => i.half.includes("up") && /Find a local match/.test(i.halfText),
"import-match-results": (i) => /Aerodynamic/.test(i.halfText),
"import-matched": (i) => !i.half.includes("up") && !/unresolved/.test(i.pltags) && /Linked to/.test(i.notice) && !i.toast,
"import-deleted": (i) => i.title.startsWith("Your library") && !/Imported playlist/.test(i.plnames) && !i.toast,
"share-import": (i) => i.title.startsWith("Import") && (i.imp > 0 || i.impErr),
"share-cancel": (i) => i.title.startsWith("Your library"),
@@ -487,12 +645,29 @@ function mediaSessionState() {
/* The audio mixer's underrun count for the app's fast track: a dropout the
* ear hears is a number here. Read before and after the walk; a rise during
* it is a finding. */
let appUidCache = null;
function appUid() {
if (appUidCache) return appUidCache;
try { appUidCache = (adb("shell", "dumpsys", "package", "com.soulstream.app").toString().match(/\buid=(\d+)/) || [])[1] || null; } catch { appUidCache = null; }
return appUidCache;
}
/* SoulStream's own tracks only: the mixer's thread counters are system-wide
and any app's notification sound moves them. Each track row of the dump
carries its client "pid/ uid" and an Underruns column (third from the
end, before Flushed and BitPerfect). */
function audioUnderruns() {
try {
const uid = appUid();
if (!uid) return null;
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
let n = 0;
for (const m of dump.matchAll(/Fifo frame underruns:[^\n]*n:\s*(\d+)/g)) n += +m[1];
for (const m of dump.matchAll(/numTracks=\d+ writeErrors=\d+ underruns=(\d+)/g)) n += +m[1];
for (const line of dump.split("\n")) {
const m = line.match(/(\d+)\/\s+(\d+)\s+\d+\s+\d+\s/);
if (!m || m[2] !== uid) continue;
const t = line.trim().split(/\s+/);
const u = Number(t[t.length - 3]);
if (Number.isFinite(u)) n += u;
}
return n;
} catch { return null; }
}
@@ -502,6 +677,7 @@ async function main() {
const problems = [];
let cdp = null; // reassigned by the restart phase
let underrunsBefore = null;
let underrunRise = 0; /* summed across the restart: a relaunch starts fresh tracks at zero */
let chrome = null;
let steps, expect, still, prefix;
@@ -568,11 +744,25 @@ async function main() {
cdp.on["Runtime.exceptionThrown"] = (p) => problems.push("EXC " + (p.exceptionDetails?.exception?.description || p.exceptionDetails?.text));
cdp.on["Runtime.consoleAPICalled"] = (p) => {
if (p.type !== "error" && p.type !== "warning") return;
/* the reload step's own consequence: a pending IPC answer with nobody left to receive it */
if (p.args.some((x) => /Couldn't find callback id/.test(String(x.value ?? "")))) return;
const frames = (p.stackTrace?.callFrames || []).slice(0, 5)
.map((f) => (f.functionName || "?") + "@" + (f.url || "").split("/").pop() + ":" + (f.lineNumber + 1) + ":" + (f.columnNumber + 1));
problems.push(p.type.toUpperCase() + " " + p.args.map((a) => a.value ?? a.description).join(" ") + (frames.length ? "\n at " + frames.join("\n at ") : ""));
};
cdp.on["Log.entryAdded"] = (p) => { if (p.entry.level === "error") problems.push("LOG " + p.entry.text + " " + (p.entry.url || "")); };
/* after a reload the WebView may present a NEW page target: the old
socket then never answers. Attach again, keeping the handlers. */
const reattach = async () => {
const on = cdp.on;
try { cdp.close(); } catch { /* already gone */ }
const a = PHONE ? await attachPhone(PORT) : { cdp: await CDP.attach(PORT) };
cdp = a.cdp;
cdp.on = on;
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Log.enable");
};
if (!PHONE) {
await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: `try { localStorage.setItem('mockLibrary', '1'); } catch (e) {}` });
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 411, height: 914, deviceScaleFactor: 2, mobile: true });
@@ -592,6 +782,9 @@ async function main() {
} catch (e) { problems.push("STEP " + (s.name || s.do) + ": " + e.message); }
}
await sleep(s.wait || 500);
if (s.do === "location.reload()") {
try { await reattach(); } catch (e) { problems.push("STEP " + (s.name || "reload") + ": could not re-attach after the reload: " + e.message); }
}
if (!s.name) continue;
await still(path.join(OUT, `${prefix}${s.name}.png`));
const info = await cdp.evaluate(PROBE);
@@ -608,9 +801,11 @@ async function main() {
const PLAYER = `(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, pos: Math.round(p.position), status: p.status, needs: !!p.needsResource } : null; })()`;
const before = await cdp.evaluate(PLAYER);
cdp.close();
if (underrunsBefore != null) { const now = audioUnderruns(); if (now != null) underrunRise += Math.max(0, now - underrunsBefore); }
adb("shell", "am", "force-stop", "com.soulstream.app");
await sleep(800);
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
underrunsBefore = null; /* re-read once the relaunch plays */
/* a relaunch restores a PAUSED track, which starts the media service
as a foreground service that must promote itself at once — on
2026-09-13 it did not, and Android killed the app ten seconds
@@ -618,6 +813,7 @@ async function main() {
resuming, and the pid must be the same one afterwards. */
adb("logcat", "-c");
await sleep(13000);
underrunsBefore = audioUnderruns();
const pid2 = adb("shell", "pidof", "com.soulstream.app").toString().trim();
const fatal = (adb("logcat", "-d", "-s", "AndroidRuntime:E").toString().match(/FATAL EXCEPTION|DidNotStartInTime/g) || []).length;
const alive = !!pid2 && !fatal;
@@ -754,6 +950,24 @@ async function main() {
const peersOk = !!netInfo && netInfo.peers >= 1;
if (!peersOk) problems.push("EXPECT net-peers " + JSON.stringify(netInfo));
console.log((peersOk ? " ok " : " FAIL ") + "net-peers".padEnd(16) + " " + JSON.stringify(netInfo));
/* a peer's sheet → their shares browsed (one request; a big
share takes seconds) → back. A share that will not open
is reported by the screen, not a failure of the port. */
try {
await cdp.evaluate(`document.querySelector('.peer-head .peer-menu').click()`);
await sleep(700);
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__click('.halfsheet .row', 'Browse their files')`);
const BRW = `(function () { return { title: (document.querySelector('.gb-t') || {}).textContent || '', dirs: document.querySelectorAll('.brw-dir').length, busy: !!document.querySelector('.imp-wait'), err: [].some.call(document.querySelectorAll('.screen .stub'), function (x) { return /shares/.test(x.textContent); }), head: (document.querySelector('.brw-head .m') || {}).textContent || '' }; })()`;
let brw = null;
for (let i = 0; i < 40; i++) { await sleep(500); brw = await cdp.evaluate(BRW); if (brw.dirs > 0 || brw.err) break; }
await still(path.join(OUT, `${prefix}net-browse.png`));
const brwOk = !!brw && (brw.dirs > 0 || brw.err);
if (!brwOk) problems.push("EXPECT net-browse " + JSON.stringify(brw));
console.log((brwOk ? " ok " : " FAIL ") + "net-browse".padEnd(16) + " " + JSON.stringify(brw));
await cdp.evaluate(`history.back()`);
await sleep(800);
} catch (e) { problems.push("STEP net-browse: " + e.message); }
await cdp.evaluate(HELPERS);
await cdp.evaluate(`__settle()`);
await sleep(800);
@@ -775,11 +989,12 @@ async function main() {
if (cdp) cdp.close();
if (chrome) chrome.kill();
}
if (PHONE && underrunsBefore != null) {
if (PHONE) {
const after = audioUnderruns();
const grew = after != null && after > underrunsBefore;
if (grew) problems.push(`AUDIO underruns rose during the walk: ${underrunsBefore} → ${after}`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` mixer underruns ${underrunsBefore} → ${after}`);
const rise = underrunRise + (underrunsBefore != null && after != null ? Math.max(0, after - underrunsBefore) : 0);
const grew = rise > 0;
if (grew) problems.push(`AUDIO SoulStream's tracks underran ${rise} time${rise === 1 ? "" : "s"} during the walk`);
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` SoulStream's tracks: ${rise} underrun${rise === 1 ? "" : "s"} during the walk`);
}
/* the two toasts a Tauri-less page always shows are not findings */
const real = PHONE ? problems : problems.filter((p) => !/transformCallback|reading 'invoke'/.test(p));