The editor the desktop drives with a mouse is better under a thumb: the song plays, the line to time sits large in the middle of the screen with the one behind and the one ahead faint around it, and a tap anywhere stamps it. One layer the back gesture peels, and a set left mid-tap is offered back by the row that opened it; Save writes the track's own document, lines never tapped left out of it and the button saying how many are kept. The lyrics sheet also gains the per-track auto-sync switch and the desktop's reduced-vocals copy — the same title, artist and duration, so the words keep following it, and one row puts the original back.
1216 lines
95 KiB
JavaScript
1216 lines
95 KiB
JavaScript
/* Walk the phone UI and say what it showed.
|
|
|
|
Two targets, one driver:
|
|
|
|
node tests/mobile/smoke.mjs headless Chrome + the mock library
|
|
node tests/mobile/smoke.mjs --phone the app on the connected phone, over
|
|
the WebView's own DevTools socket
|
|
|
|
MOCK: builds the phone document with relative asset paths into dist-smoke/,
|
|
opens it from file:// with src/lib/mockLibrary.js switched on, drives the
|
|
screens, screenshots each step, asserts what each should show, and fails
|
|
on any exception or console error. No Tauri there, so playback commands
|
|
reject — the point is the UI's own logic and its CSS, before the phone.
|
|
|
|
PHONE: a debug build's WebView exposes DevTools on an abstract socket;
|
|
`adb forward` puts it on a local port and the same protocol drives the
|
|
real app on the real library — real playback, real covers, real colour.
|
|
Stills are the phone's own frame (adb screencap), status bar included.
|
|
`--album <text>` picks the record to walk by title (default: the newest).
|
|
|
|
Stills land in tests/mobile/out/ (or the directory given as --out). */
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { CDP, adb, sleep, attachPhone, phoneStill } from "./cdp.mjs";
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const ARGS = process.argv.slice(2);
|
|
const flag = (name) => { const i = ARGS.indexOf(name); return i >= 0 ? (ARGS[i + 1] ?? true) : null; };
|
|
const PHONE = ARGS.includes("--phone");
|
|
const OUT = path.resolve(flag("--out") || path.join(ROOT, "tests", "mobile", "out"));
|
|
const ALBUM = flag("--album");
|
|
const PORT = PHONE ? 9222 : 9421;
|
|
|
|
const CHROME = [
|
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
path.join(process.env.LOCALAPPDATA || "", "Google/Chrome/Application/chrome.exe"),
|
|
].find((p) => fs.existsSync(p));
|
|
/* injected beside the steps: click the first element whose text mentions
|
|
something, because the real library is a list of names, not of indices */
|
|
const HELPERS = `
|
|
window.__click = function (sel, text) {
|
|
var all = document.querySelectorAll(sel);
|
|
for (var i = 0; i < all.length; i++) {
|
|
if (!text || (all[i].textContent || '').indexOf(text) >= 0) { all[i].click(); return true; }
|
|
}
|
|
throw new Error('nothing to click for ' + sel + (text ? ' ~ "' + text + '"' : ''));
|
|
};
|
|
window.__settle = function () {
|
|
/* put the app back at Home with nothing open, whatever it was doing */
|
|
var s = document.querySelector('.sheet.up');
|
|
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;
|
|
};
|
|
`;
|
|
|
|
/* what the page shows, read back after every still */
|
|
const PROBE = `(function () {
|
|
var e = document.querySelector('.screen');
|
|
return {
|
|
screen: e ? e.className : '',
|
|
title: (document.querySelector('.gb-t') || {}).textContent || '',
|
|
mini: !!document.querySelector('.mini'),
|
|
sheet: (document.querySelector('.sheet') || {}).className || '',
|
|
half: (document.querySelector('.halfsheet') || {}).className || '',
|
|
halfText: (document.querySelector('.halfsheet .hs-body') || {}).textContent || '',
|
|
accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(),
|
|
amb: [].map.call(document.querySelectorAll('.app-bg .amb'), function (a) { return a.className + ':' + (a.style.backgroundImage ? 'img' : '-'); }).join(' '),
|
|
playing: (document.querySelector('.mini .t') || {}).textContent || '',
|
|
toast: (document.querySelector('.toast:not(.notice)') || {}).textContent || '',
|
|
live: (document.querySelector('.mini .m.live') || {}).textContent || '',
|
|
peers: document.querySelectorAll('.peer').length,
|
|
songs: document.querySelectorAll('.cat-song').length,
|
|
msgs: document.querySelectorAll('.msg').length,
|
|
lane: (document.querySelector('.sheet .lane') || {}).className || '',
|
|
top: (document.querySelector('.top .top-name') || {}).textContent || '',
|
|
tabs: document.querySelectorAll('.disco-tabs button').length,
|
|
pop: document.querySelectorAll('.row.pop').length,
|
|
fb: (document.querySelector('.stats .fb') || {}).textContent || '',
|
|
heroImg: !!document.querySelector('.top-art img'),
|
|
socs: document.querySelectorAll('.stats .soc').length,
|
|
eds: document.querySelectorAll('.eds .ed').length,
|
|
lede: !!document.querySelector('.lede'),
|
|
bars: document.querySelectorAll('.trk .lbar').length,
|
|
save: (document.querySelector('.rec-acts .save') || {}).textContent || '',
|
|
xfers: document.querySelectorAll('.row.xfer').length,
|
|
feat: (document.querySelector('.feat .kick') || {}).textContent || '',
|
|
gaplines: document.querySelectorAll('.gapline').length,
|
|
folderRow: !![].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Library folder/.test(x.textContent); }),
|
|
norm: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Volume normalization/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
|
gapCards: document.querySelectorAll('.rail.gaps .release').length,
|
|
news: document.querySelectorAll('.row.news').length,
|
|
secs: [].map.call(document.querySelectorAll('.screen .sec h2'), function (h) { return h.firstChild && h.firstChild.textContent ? h.firstChild.textContent.trim() : ''; }).join('|'),
|
|
xlive: (document.querySelector('.screen .sec h2') || {}).textContent || '',
|
|
plcards: document.querySelectorAll('.plgrid .release').length,
|
|
plnames: [].map.call(document.querySelectorAll('.plgrid .release .title'), function (t) { return t.textContent.trim(); }).join('|'),
|
|
plpinned: !!document.querySelector('.plsec.pinned'),
|
|
plrows: document.querySelectorAll('.pl-rows .trk').length,
|
|
pltags: [].map.call(document.querySelectorAll('.pl-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
|
|
plkind: (document.querySelector('.rec-head.pl .kind') || {}).textContent || '',
|
|
plfilter: !!document.querySelector('.plfilter input'),
|
|
plfirst: (document.querySelector('.pl-rows .trk .t') || {}).textContent || '',
|
|
plcover: !!document.querySelector('.rec-head.pl .rec-cover img'),
|
|
qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '',
|
|
rooms: document.querySelectorAll('.screen button.row .art.hash').length,
|
|
jump: document.querySelectorAll('.screen .rail .release').length,
|
|
countline: (document.querySelector('.countline .t-sub') || {}).textContent || '',
|
|
navBadges: [].map.call(document.querySelectorAll('.nav .nb'), function (b) { return b.textContent.trim(); }).join('|'),
|
|
wifiOnly: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
|
firstBadge: (document.querySelector('.grid2 .release .ed-badge') || {}).textContent || '',
|
|
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
|
|
imp: document.querySelectorAll('.imp-rows .trk').length,
|
|
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
|
|
impCounts: (document.querySelector('.imp-counts') || {}).textContent || '',
|
|
impFmt: (document.querySelector('.imp-head .stats .s') || {}).textContent || '',
|
|
impWait: !!document.querySelector('.imp-wait'),
|
|
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'),
|
|
fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length,
|
|
played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '',
|
|
lyrLines: document.querySelectorAll('.lyr .ln').length,
|
|
sync: !!document.querySelector('.syncroom'),
|
|
syCount: (document.querySelector('.sy-count') || {}).textContent || '',
|
|
syNow: (document.querySelector('.sy-now') || {}).textContent || '',
|
|
lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
|
|
trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(),
|
|
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 || '',
|
|
peerLine: (document.querySelector('.pr-head .m') || {}).textContent || '',
|
|
peerDesc: (document.querySelector('.pr-about') || {}).textContent || '',
|
|
peerPic: !!document.querySelector('.pr-pic img'),
|
|
peerTags: document.querySelectorAll('.pr-tags .chip').length,
|
|
peerGaps: document.querySelectorAll('.pr-gap').length,
|
|
peerSum: (document.querySelector('.pr-sum') || {}).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,
|
|
notice: (document.querySelector('.toast.notice') || {}).textContent || '',
|
|
promptVal: (document.querySelector('.halfsheet .prompt .field') || {}).value || '',
|
|
/* the history mirror: the entry's depth against the layers the app says the back gesture can peel */
|
|
hd: (history.state && history.state.d != null) ? history.state.d : -1,
|
|
hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(),
|
|
cardImgs: document.querySelectorAll('.rail .acard img').length,
|
|
cardPhs: document.querySelectorAll('.rail .acard svg').length,
|
|
likes: [].map.call(document.querySelectorAll('.tastechips.likes .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
|
|
hates: [].map.call(document.querySelectorAll('.tastechips.hates .tchip'), function (c) { return c.textContent.trim(); }).join('|'),
|
|
similar: document.querySelectorAll('.screen .row.similar').length,
|
|
budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'),
|
|
budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'),
|
|
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
|
|
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
|
|
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
|
|
};
|
|
})()`;
|
|
|
|
/* ── the walks ─────────────────────────────────────────────────────────── */
|
|
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: `__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 },
|
|
/* By format: the hi-res lossless records come first */
|
|
{ do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 500 },
|
|
{ do: `__click('.chips .chip', 'Recently added')`, wait: 400 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 },
|
|
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "album", wait: 900 },
|
|
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 900 },
|
|
{ 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 },
|
|
/* the second upcoming row moved up */
|
|
{ do: `(function () { globalThis.__q2 = document.querySelectorAll('.q-face .row')[1].querySelector('.t').textContent; document.querySelectorAll('.q-face .row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Move up')`, name: "queue-moved-up", 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 },
|
|
/* pasted words, timestamped: synced lines */
|
|
{ do: `__click('.lyr-tools', null)`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Paste lyrics')`, wait: 600 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "lyrics-pasted", wait: 800 },
|
|
/* auto-sync: the app's own correction turned off for this track, then
|
|
back on — the re-read leaves the sheet up so the switch is seen */
|
|
{ do: `__click('.lyr-tools', null)`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 },
|
|
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 },
|
|
/* off again, and left off: a save returns the document with that flag at
|
|
its default, and the switch must not take it */
|
|
{ do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 },
|
|
/* the tap-to-sync room: taps become the timing, one undone, the room
|
|
peeled by the back gesture and the set offered back, then saved */
|
|
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
|
|
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
|
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
|
|
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
|
|
{ do: `history.back()`, wait: 600 },
|
|
{ do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 },
|
|
{ do: `__click('.sy-pad', null)`, wait: 400 },
|
|
{ do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 },
|
|
{ do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 },
|
|
/* the vocal-reduced copy, and back to the recording */
|
|
{ do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 },
|
|
{ do: `__click('.lyr-tools', null)`, wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 },
|
|
{ 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 },
|
|
/* the contact editor behind the peer sheet: a petname, a note, the
|
|
favourite mark — then the buddy list wearing all three, ada's own
|
|
name still under it and favourites first */
|
|
{ do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Petname')`, wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 },
|
|
{ do: `__click('.halfsheet .row', 'Note')`, wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 },
|
|
{ do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 },
|
|
{ do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 },
|
|
{ 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 },
|
|
/* your own interests: what is declared, a like added and the server's
|
|
match re-asked, a hate added, the like taken back, a match's sheet */
|
|
{ do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 200 },
|
|
{ do: `__click('.segtabs button', 'Taste')`, name: "taste", wait: 1000 },
|
|
{ do: `__click('.tastechips.likes .tadd', null)`, wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Boards of Canada'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-declared", wait: 1100 },
|
|
{ do: `__click('.tastechips.hates .tadd', null)`, wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'loudness war 2'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Declare')`, name: "taste-hated", wait: 1100 },
|
|
{ do: `__click('.tastechips.likes .tchip', 'Boards of Canada')`, name: "taste-dropped", wait: 1100 },
|
|
{ do: `__click('.screen .row.similar', null)`, name: "taste-peer", wait: 700 },
|
|
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
|
{ do: `__click('.segtabs button', 'Rooms')`, wait: 500 },
|
|
/* the room list: filtered by name, the rest on demand */
|
|
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = 'vin'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "rooms-filtered", wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.roomfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
|
{ do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 },
|
|
/* the fake network: a room with a few lines in it */
|
|
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
|
|
/* the room's roster wears the petname too, the real name beneath it */
|
|
{ do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 },
|
|
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
|
{ do: `history.back()`, wait: 700 },
|
|
/* nothing set is nothing stored: cleared, the entry leaves the list and
|
|
a name goes back to being a name */
|
|
{ do: `__click('.segtabs button', 'Buddies')`, wait: 600 },
|
|
{ do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 },
|
|
{ do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 },
|
|
{ do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 },
|
|
/* a search answered by five peers; the best one's first file streams,
|
|
the lane rising as the bytes arrive */
|
|
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 },
|
|
{ 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 },
|
|
{ do: `__click('.rail .acard', 'Justice')`, name: "cat-artist-related", wait: 1800 },
|
|
{ do: `history.back()`, wait: 600 },
|
|
{ do: `history.back()`, wait: 800 },
|
|
{ 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 },
|
|
/* the hub's filters: FLAC only, then a free slot only */
|
|
{ do: `__click('.netfilters .chip', 'FLAC')`, name: "net-filter-flac", wait: 500 },
|
|
{ do: `__click('.netfilters .chip', 'Free slot')`, name: "net-filter-free", wait: 500 },
|
|
{ do: `__click('.netfilters .chip', 'Free slot')`, wait: 300 },
|
|
{ do: `__click('.netfilters .chip', 'Any format')`, wait: 400 },
|
|
{ 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 },
|
|
/* the stranger in full: the card their client serves, their picture in
|
|
the viewer, what they declared, and the comparison of their share
|
|
against this library — a record they have and it doesn't, found */
|
|
{ do: `__click('.halfsheet .row', 'About this user')`, name: "peer-screen", wait: 1400 },
|
|
{ do: `document.querySelector('.pr-pic').click()`, name: "peer-picture", wait: 900 },
|
|
{ do: `history.back()`, wait: 700 },
|
|
{ do: `__click('.screen .btn', 'Compare with my library')`, name: "peer-compare", wait: 2200 },
|
|
{ do: `__click('.pr-gap', 'Geogaddi')`, name: "peer-gap-open", wait: 1300 },
|
|
{ do: `history.back()`, name: "peer-again", wait: 1200 },
|
|
{ do: `__click('.screen .btn', 'Forget this comparison')`, name: "peer-forgotten", wait: 1100 },
|
|
{ do: `document.querySelector('.pr-head .iconbtn').click()`, name: "peer-sheet-again", 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 },
|
|
/* Save: the best folder by the download rules, every file fetched */
|
|
/* one track: the long press's sheet, Save to library */
|
|
{ do: `document.querySelectorAll('.tracks .trk')[3].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "cat-track-sheet", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Save to library')`, name: "cat-track-save", wait: 3500 },
|
|
{ do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); })()`, wait: 500 },
|
|
{ do: `document.querySelector('.rec-acts .save').click()`, name: "cat-album-save", wait: 4500 },
|
|
/* the burst on the Transfers screen, reached from You */
|
|
{ do: `__settle()`, wait: 700 },
|
|
{ do: `document.querySelectorAll('.nav button')[4].click()`, wait: 600 },
|
|
{ do: `__click('.screen .row', 'Transfers')`, name: "transfers", wait: 1200 },
|
|
{ do: `history.back()`, wait: 600 },
|
|
/* Gaps: the followed shelf, its missing records dimmed */
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
|
{ do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 800 },
|
|
{ do: `__click('.gapline', null)`, name: "gaps-open", wait: 1600 },
|
|
/* Playlists: the shelf (pinned first), a page holding every row kind,
|
|
a local row playing, the stream row through the resolver, the
|
|
filter, a removal and its undo, the page's sheet — unpin, rename —
|
|
a new playlist from the shelf, a record's tracks added to it from
|
|
the record's sheet, and its deletion */
|
|
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
|
|
{ do: `__click('.plgrid .release', 'Late night')`, name: "playlist", wait: 1200 },
|
|
{ do: `document.querySelectorAll('.pl-rows .trk')[1].click()`, name: "playlist-playing", wait: 1200 },
|
|
{ do: `__click('.pl-rows .trk', 'One More Time')`, name: "playlist-stream", wait: 5000 },
|
|
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'track 4'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 500 },
|
|
{ do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 },
|
|
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 },
|
|
{ do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1000 },
|
|
/* reordered from the sheet: the second row up, then back down */
|
|
{ do: `document.querySelectorAll('.pl-rows .trk')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Move up')`, name: "playlist-moved-up", wait: 900 },
|
|
{ do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Move down')`, name: "playlist-moved-down", wait: 900 },
|
|
/* the bulk actions from the page's sheet: Add to library files the
|
|
streamed row (its cache is complete); Download all finds it cached too */
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Add to library')`, name: "playlist-bulk-cached", wait: 1500 },
|
|
{ do: `__click('.bulkline .bulkx', null)`, wait: 400 },
|
|
{ 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 },
|
|
/* the cover: the custom one removed (the mosaic), then a photo chosen */
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Remove custom cover')`, name: "cover-removed", wait: 1000 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Choose cover photo')`, name: "cover-set", wait: 1000 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Rename')`, name: "playlist-rename", wait: 700 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Late night drive II'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Rename')`, name: "playlist-renamed", wait: 1000 },
|
|
{ do: `history.back()`, name: "playlists-again", wait: 900 },
|
|
{ do: `__click('.newpl', 'New playlist')`, name: "playlist-new-prompt", wait: 700 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1200 },
|
|
{ do: `history.back()`, wait: 800 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
|
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'New Playlist')`, name: "playlist-added", wait: 1200 },
|
|
{ do: `__click('.segtabs button', 'Playlists')`, wait: 700 },
|
|
{ do: `__click('.plgrid .release', 'New Playlist')`, name: "playlist-filled", wait: 1200 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ do: `__click('.halfsheet .row', 'Delete playlist')`, name: "playlist-delete-confirm", wait: 700 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1200 },
|
|
/* the import doors: a pasted tracklist reviewed and created with the
|
|
unresolved row kept out; then a link — its stream rows verified
|
|
against the catalog while the review is up — created as it came */
|
|
{ do: `__click('.newpl', 'Import')`, name: "import-sheet", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Paste a tracklist')`, name: "import-paste-prompt", wait: 700 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nAphex Twin - Track 1\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 2200 },
|
|
{ do: `__click('.imp-form .row', 'Keep unresolved')`, name: "import-keep-off", wait: 500 },
|
|
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 1500 },
|
|
{ do: `history.back()`, wait: 900 },
|
|
{ do: `__click('.newpl', 'Import')`, wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Playlist from a link')`, wait: 700 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-link-wait", wait: 500 },
|
|
{ name: "import-link-review", wait: 3400 },
|
|
{ do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-link-created", wait: 1500 },
|
|
{ do: `history.back()`, name: "playlists-after-import", wait: 900 },
|
|
/* a share from another app: the plugin hands the text over when the
|
|
app comes to the front, and it lands in the import */
|
|
{ do: `__ssMock.share = 'Check this out https://music.apple.com/us/playlist/late/pl.u-mock'; document.dispatchEvent(new Event('visibilitychange'))`, name: "share-import", wait: 3600 },
|
|
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
|
{ do: `__settle()`, wait: 800 },
|
|
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 700 },
|
|
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 500 },
|
|
{ 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 },
|
|
/* downloads on Wi-Fi only: the switch on, the connection made cellular, a download refused; then off */
|
|
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-on", wait: 400 },
|
|
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 },
|
|
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
|
|
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-off", 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 },
|
|
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 },
|
|
{ do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 },
|
|
];
|
|
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) && /Name and notes/.test(i.halfText),
|
|
"user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText),
|
|
/* the local contact metadata: the editor, the petname reaching the
|
|
backend, the mark sticking, the buddy list rearranged — and the
|
|
cleared entry dropped rather than stored empty */
|
|
"contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere",
|
|
"contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "),
|
|
"contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText),
|
|
"contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite",
|
|
"buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast,
|
|
"room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText),
|
|
"contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast,
|
|
taste: (i) => i.likes === "Daft Punk|french house|Warp Records" && i.hates === "loudness war" && i.similar === 4,
|
|
"taste-declared": (i) => /Boards of Canada/.test(i.likes) && i.similar === 5,
|
|
"taste-hated": (i) => i.hates === "loudness war|loudness war 2",
|
|
"taste-dropped": (i) => !/Boards of Canada/.test(i.likes) && i.similar === 4 && !i.toast,
|
|
"taste-peer": (i) => i.half.includes("up") && /hifi_hank/.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",
|
|
"wifi-on": (i) => i.wifiOnly === "on",
|
|
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
|
|
"wifi-off": (i) => i.wifiOnly === "off" && !i.toast,
|
|
"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 && i.jump >= 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") && /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") && i.fmtBadges >= 10 && /hours/.test(i.countline),
|
|
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,
|
|
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
|
|
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
|
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
|
|
"sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow,
|
|
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
|
|
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
|
|
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
|
|
"sync-resumed": (i) => i.sync && i.syCount === "1 / 3",
|
|
"sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow),
|
|
"sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast,
|
|
/* the save's own payload carries no auto-sync flag: the switch kept its */
|
|
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
|
|
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
|
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
|
|
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") && /played/.test(i.played),
|
|
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,
|
|
"queue-moved-up": (i) => !i.half.includes("up") && !!i.qfirst && 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,
|
|
complete the set, following — the desktop's page */
|
|
"home-again": (i) => /^(Good |Still)/.test(i.title) && /^Coming/.test(i.feat) && i.news >= 1 && /New from your artists/.test(i.secs) && /From the back catalogue/.test(i.secs) && /Recommended for you/.test(i.secs) && /Following/.test(i.secs),
|
|
room: (i) => i.screen.includes("tr-push") && i.msgs >= 2,
|
|
"rooms-filtered": (i) => i.rooms === 1,
|
|
"rooms-more": (i) => i.rooms >= 95,
|
|
/* 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,
|
|
"cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero"),
|
|
"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,
|
|
"net-filter-flac": (i) => i.peers === 3,
|
|
"net-filter-free": (i) => i.peers === 2,
|
|
"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),
|
|
"peer-screen": (i) => i.title.startsWith("hifi_hank") && /Ripping my own vinyl/.test(i.peerDesc) && i.peerPic && i.peerTags === 6 && /slot is free/.test(i.peerLine) && /served/.test(i.peerLine) && i.peerGaps === 0,
|
|
"peer-picture": (i) => i.viewer,
|
|
"peer-compare": (i) => i.peerGaps === 6 && /in your library/.test(i.peerSum),
|
|
"peer-gap-open": (i) => i.title.startsWith("Search") && i.sq === "Boards of Canada Geogaddi",
|
|
"peer-again": (i) => i.title.startsWith("hifi_hank") && i.peerGaps === 6,
|
|
"peer-forgotten": (i) => i.peerGaps === 0 && !i.peerSum && /forgotten/i.test(i.notice),
|
|
"peer-sheet-again": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && !/About this user/.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) && /^\d+$/.test(i.navBadges),
|
|
transfers: (i) => i.title.startsWith("Transfers") && i.xfers >= 11,
|
|
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
|
|
"gaps-open": (i) => i.gapCards >= 3,
|
|
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
|
|
"library-by-format": (i) => /^FLAC 24/.test(i.firstBadge),
|
|
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3,
|
|
playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter,
|
|
"playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast,
|
|
"playlist-stream": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
|
|
"playlist-filter": (i) => i.plrows === 1,
|
|
"playlist-row-sheet": (i) => i.half.includes("up") && /Remove from playlist/.test(i.halfText),
|
|
"playlist-removed": (i) => i.plrows === 5 && /Removed/.test(i.notice),
|
|
"playlist-undone": (i) => i.plrows === 6 && !i.notice,
|
|
"playlist-moved-up": (i) => i.plfirst.startsWith("Track 2"),
|
|
"playlist-moved-down": (i) => i.plfirst.startsWith("Track 1"),
|
|
"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),
|
|
"cover-removed": (i) => !i.plcover && /mosaic/.test(i.notice),
|
|
"cover-set": (i) => i.plcover && /Cover set/.test(i.notice),
|
|
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
|
|
"playlist-renamed": (i) => i.title.startsWith("Late night drive II"),
|
|
"playlists-again": (i) => i.plcards === 3 && !i.plpinned && /Late night drive II/.test(i.plnames),
|
|
"playlist-new-prompt": (i) => i.half.includes("up") && /^New Playlist \d+$/.test(i.promptVal),
|
|
"playlist-new": (i) => i.screen.includes("tr-push") && /^New Playlist/.test(i.title) && i.plrows === 0,
|
|
"playlist-pick": (i) => i.half.includes("up") && /New playlist/.test(i.halfText) && /Late night drive II/.test(i.halfText),
|
|
"playlist-added": (i) => /Added \d+ tracks to New Playlist/.test(i.notice),
|
|
"playlist-filled": (i) => /^New Playlist/.test(i.title) && i.plrows >= 5,
|
|
"playlist-delete-confirm": (i) => i.half.includes("up") && /Delete/.test(i.halfText),
|
|
"playlist-deleted": (i) => i.title.startsWith("Your library") && i.plcards === 3 && !/New Playlist/.test(i.plnames),
|
|
"import-sheet": (i) => i.half.includes("up") && /Playlist from a link/.test(i.halfText) && /Paste a tracklist/.test(i.halfText),
|
|
"import-paste-prompt": (i) => i.half.includes("up") && /Paste a tracklist/.test(i.halfText) && i.promptVal === "",
|
|
"import-review": (i) => i.title.startsWith("Import") && i.imp === 3 && /1 in library/.test(i.impCounts) && /tags/.test(i.impTags) && /unresolved/.test(i.impTags) && /catalog/.test(i.impTags) && i.keepOn && /Create playlist \(3 tracks\)/.test(i.impBtn),
|
|
"import-keep-off": (i) => !i.keepOn && /Create playlist \(2 tracks\)/.test(i.impBtn),
|
|
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2,
|
|
/* the wait card is transient: the still can take longer than the mock's fetch */
|
|
"import-link-wait": (i) => i.title.startsWith("Import") && (i.impWait || i.imp === 5),
|
|
"import-link-review": (i) => i.imp === 5 && /Spotify/.test(i.impFmt) && /catalog/.test(i.impTags) && !/matching/.test(i.impTags) && /Create playlist \(5 tracks\)/.test(i.impBtn),
|
|
"import-link-created": (i) => i.title.startsWith("Late Night Mix") && i.plrows === 5,
|
|
"playlists-after-import": (i) => i.title.startsWith("Your library") && i.plcards === 5,
|
|
"share-import": (i) => i.title.startsWith("Import") && /Apple Music/.test(i.impFmt) && i.imp === 5,
|
|
"share-cancel": (i) => i.title.startsWith("Your library"),
|
|
"cat-album-row": (i) => i.playing.includes("Aerodynamic") && /streaming from/.test(i.live),
|
|
"cat-album-next": (i) => i.playing.includes("Digital Love") && /streaming from/.test(i.live),
|
|
"cat-album-disc": (i) => i.playing.includes("One More Time") && /streaming from/.test(i.live),
|
|
};
|
|
|
|
/* the phone walk: a record by name, played for real */
|
|
const albumText = ALBUM ? JSON.stringify(ALBUM) : "null";
|
|
const PHONE_STEPS = [
|
|
{ do: `__settle()`, wait: 900 },
|
|
{ name: "home", wait: 300 },
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
|
/* the Library root remembers its tab across runs — back to Albums first */
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
|
{ do: `__click('.grid2 .release', ${albumText})`, name: "album", wait: 1400 },
|
|
{ do: `document.querySelector('.rec-acts .disc').click()`, name: "album-playing", wait: 3200 },
|
|
{ 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 },
|
|
/* auto-sync off and straight back on — the cache entry ends as it began */
|
|
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 },
|
|
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 },
|
|
/* the tap room on the real clock: two taps, one undone, then discarded —
|
|
nothing is written to Kyle's own lyrics for this track */
|
|
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 },
|
|
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
|
|
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
|
|
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
|
|
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
|
|
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
|
|
{ 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 */
|
|
{ do: `location.reload()`, name: "after-reload", wait: 4000 },
|
|
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 700 },
|
|
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'the'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search", wait: 1200 },
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
|
{ do: `document.querySelector('.grid2 .release').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "hold-sheet", wait: 900 },
|
|
{ do: `document.querySelector('.scrim').click()`, wait: 600 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 1200 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
|
/* the artist page: the library's shell at once, the catalog dissolving in */
|
|
/* button.row: the page header is a .row too, and it is a div */
|
|
/* playlists on the real core: one made here, a record's tracks added
|
|
from the record's sheet, a row played, the playlist deleted again —
|
|
the phone's library is left as it was */
|
|
{ do: `__click('.segtabs button', 'Playlists')`, name: "playlists", wait: 900 },
|
|
{ do: `__click('.newpl', 'New playlist')`, wait: 700 },
|
|
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Walk test'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Create')`, name: "playlist-new", wait: 1500 },
|
|
{ do: `history.back()`, wait: 800 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
|
{ do: `(function () { var all = document.querySelectorAll('.grid2 .release'); var c = null; for (var i = 0; i < all.length; i++) { if (!${albumText} || all[i].textContent.indexOf(${albumText}) >= 0) { c = all[i]; break; } } if (!c) throw new Error('no album card'); c.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); })()`, wait: 900 },
|
|
{ do: `__click('.halfsheet .row', 'Add to playlist')`, name: "playlist-pick", wait: 800 },
|
|
{ do: `__click('.halfsheet .row', 'Walk test')`, name: "playlist-added", wait: 1500 },
|
|
{ do: `__click('.segtabs button', 'Playlists')`, wait: 800 },
|
|
{ do: `__click('.plgrid .release', 'Walk test')`, name: "playlist-filled", wait: 1500 },
|
|
{ do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 800 },
|
|
{ do: `__click('.halfsheet .row', 'Delete playlist')`, wait: 700 },
|
|
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Delete')`, name: "playlist-deleted", wait: 1500 },
|
|
/* the paste door on the real core: two lines that resolve to a record
|
|
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\\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 },
|
|
/* the share sheet: a Spotify playlist link sent by "another app" (an
|
|
intent on the plugin's activity) lands in the import — the review is
|
|
left without creating anything */
|
|
{ adb: ["shell", "am", "start", "-a", "android.intent.action.SEND", "-t", "text/plain", "--es", "android.intent.extra.TEXT", "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", "-n", "com.soulstream.app/com.soulstream.share.ShareActivity"], name: "share-import", wait: 9000 },
|
|
{ do: `__click('.imp-foot .btn-ghost', null)`, name: "share-cancel", wait: 900 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
|
|
/* You: the settings save for real (camelCase args) — the switch and a
|
|
chip, each read back from the config, each restored */
|
|
{ do: `document.querySelectorAll('.nav button')[4].click()`, name: "you", wait: 900 },
|
|
{ do: `__click('.screen .row', 'Volume normalization')`, name: "you-norm-off", wait: 900 },
|
|
{ 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 },
|
|
/* the real config's contact list, read back over the real IPC */
|
|
{ do: `__ss.loadContacts()`, name: "contacts-read", wait: 800 },
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
|
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
|
|
{ name: "artist", wait: 4500 },
|
|
/* a discography card the library does NOT hold opens the catalog record */
|
|
{ do: `(function () { var c = [].slice.call(document.querySelectorAll('.grid2 .release')); if (!c.length) throw new Error('no discography grid'); var pick = c.find(function (x) { return x.textContent.indexOf('in your library') < 0; }) || c[c.length - 1]; pick.click(); })()`, name: "catalog-album", wait: 3500 },
|
|
{ do: `history.back()`, wait: 800 },
|
|
{ do: `history.back()`, wait: 800 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 400 },
|
|
{ do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 },
|
|
];
|
|
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,
|
|
/* the walked album's track may genuinely have no words, so the room's
|
|
own rows are conditional — the discard is not */
|
|
"lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"),
|
|
"lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"),
|
|
"sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)),
|
|
"sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)),
|
|
"sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)),
|
|
"sync-discarded": (i) => !i.sync && !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"),
|
|
"after-reload": (i) => i.mini && !!i.playing && i.amb.includes("on:img") && !i.toast,
|
|
search: (i) => i.title.startsWith("Search"),
|
|
"hold-sheet": (i) => i.half.includes("up"),
|
|
tracks: (i) => i.title.startsWith("Your library"),
|
|
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
|
|
"artist-cold": (i) => i.screen.includes("has-hero"),
|
|
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
|
|
"you-norm-off": (i) => i.norm === "off" && !i.toast,
|
|
"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),
|
|
"playlist-added": (i) => /Added .* to Walk test/.test(i.notice) && !i.toast,
|
|
"playlist-filled": (i) => i.title.startsWith("Walk test") && i.plrows >= 1,
|
|
"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 === 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"),
|
|
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,
|
|
"catalog-album": (i) => i.screen.includes("tr-push") && !!i.title,
|
|
end: (i) => /^(Good |Still)/.test(i.title),
|
|
};
|
|
|
|
/* The system's view of the app's media session: who owns it and what the
|
|
* playback state says. From `dumpsys media_session`. */
|
|
function mediaSessionState() {
|
|
try {
|
|
const dump = adb("shell", "dumpsys", "media_session").toString();
|
|
/* sessions are listed as blocks; find ours by package, then the first
|
|
playback state after it */
|
|
const at = dump.search(/com\.soulstream\.app/);
|
|
if (at < 0) return { owner: "", state: "" };
|
|
const block = dump.slice(Math.max(0, at - 400), at + 3000);
|
|
const state = (block.match(/state=PlaybackState \{state=([A-Z_]+)\(\d+\), position=(\d+)/) || []);
|
|
return { owner: "com.soulstream.app", state: state[1] || "", position: state[2] ? +state[2] / 1000 : null };
|
|
} catch { return { owner: "", state: "" }; }
|
|
}
|
|
|
|
/* 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 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; }
|
|
}
|
|
|
|
async function main() {
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
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;
|
|
|
|
if (PHONE && ARGS.includes("--only-lock")) {
|
|
/* the lock-screen card alone: whatever is on the bar plays, the phone
|
|
sleeps, wakes to its lock screen, and that is the still. Leaves the
|
|
phone locked. */
|
|
const attached = await attachPhone(PORT);
|
|
cdp = attached.cdp;
|
|
await cdp.send("Runtime.enable");
|
|
const st = await cdp.evaluate(`(function () { var p = window.__ss && __ss.player; return p && p.track ? { title: p.track.title, status: p.status } : null; })()`);
|
|
if (!st) { console.log("nothing on the bar to show on the lock screen"); process.exit(1); }
|
|
if (st.status !== "playing") { await cdp.evaluate(`window.__ss && __ss.toggle()`); await sleep(2500); }
|
|
cdp.close();
|
|
cdp = null;
|
|
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
|
|
await sleep(2000);
|
|
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
|
|
await sleep(2500);
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
await phoneStill(path.join(OUT, "phone-lockscreen.png"));
|
|
console.log(" -- lockscreen still taken (" + st.title + "); the phone is locked now");
|
|
process.exit(0);
|
|
}
|
|
if (PHONE) {
|
|
const attached = await attachPhone(PORT);
|
|
cdp = attached.cdp;
|
|
const pid = attached.pid;
|
|
underrunsBefore = audioUnderruns();
|
|
steps = PHONE_STEPS; expect = PHONE_EXPECT; prefix = "phone-";
|
|
still = phoneStill;
|
|
console.log("attached to the phone's WebView (pid " + pid + ")");
|
|
} else {
|
|
if (!CHROME) throw new Error("Chrome not found");
|
|
console.log("building dist-smoke/ …");
|
|
/* unminified, so a warning's frames name the function */
|
|
const b = spawnSync("npx", ["vite", "build", "--outDir", "dist-smoke", "--base", "./", "--minify", "false"], { cwd: ROOT, shell: true, encoding: "utf8" });
|
|
if (b.status !== 0) { console.error(b.stdout, b.stderr); throw new Error("vite build failed"); }
|
|
/* a profile per run: a Chrome left behind by an interrupted run keeps
|
|
its crashpad file open, and removing that profile throws EBUSY */
|
|
const profile = path.join(os.tmpdir(), "ss-smoke-profile-" + process.pid);
|
|
for (const d of fs.readdirSync(os.tmpdir())) {
|
|
if (d.startsWith("ss-smoke-profile")) {
|
|
try { fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true }); } catch { /* still held */ }
|
|
}
|
|
}
|
|
chrome = spawn(CHROME, [
|
|
"--headless=new", "--disable-gpu", "--hide-scrollbars", "--mute-audio",
|
|
"--allow-file-access-from-files", `--user-data-dir=${profile}`,
|
|
`--remote-debugging-port=${PORT}`, "--window-size=411,914", "about:blank",
|
|
], { stdio: "ignore" });
|
|
cdp = await CDP.attach(PORT);
|
|
steps = MOCK_STEPS; expect = MOCK_EXPECT; prefix = "";
|
|
still = async (file) => {
|
|
const shot = await cdp.send("Page.captureScreenshot", { format: "png" });
|
|
fs.writeFileSync(file, Buffer.from(shot.result.data, "base64"));
|
|
};
|
|
}
|
|
|
|
try {
|
|
await cdp.send("Page.enable");
|
|
await cdp.send("Runtime.enable");
|
|
await cdp.send("Log.enable");
|
|
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 });
|
|
await cdp.send("Page.navigate", { url: pathToFileURL(path.join(ROOT, "dist-smoke", "mobile.html")).href });
|
|
await sleep(600);
|
|
}
|
|
for (const s of steps) {
|
|
/* a phone step can be an adb command — an intent, a key */
|
|
if (s.adb) {
|
|
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
|
|
}
|
|
if (s.do) {
|
|
try {
|
|
/* the helpers live in the page; a reload step wipes them */
|
|
if (s.do.includes("__")) await cdp.evaluate(HELPERS);
|
|
await cdp.evaluate(s.do);
|
|
} 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);
|
|
const ok = expect[s.name] ? expect[s.name](info) : true;
|
|
if (!ok) problems.push("EXPECT " + s.name + " " + JSON.stringify(info));
|
|
console.log((ok ? " ok " : " FAIL ") + s.name.padEnd(16) + " " + JSON.stringify(info));
|
|
}
|
|
|
|
/* ── the restart half of session restore, on the phone only ──────
|
|
A reload kept the audio thread (tested above); a real restart
|
|
loses it. The track must come back on the bar, paused, and play
|
|
must pick it up where it was rather than at zero. */
|
|
if (PHONE) {
|
|
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
|
|
later (a Save died with it). Wait past that window BEFORE
|
|
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;
|
|
if (!alive) problems.push("EXPECT survives-paused-restore " + JSON.stringify({ pid: pid2, fatal }));
|
|
console.log((alive ? " ok " : " FAIL ") + "paused-restore".padEnd(16) + " " + JSON.stringify({ pid: pid2 || null, fatal }));
|
|
adb("forward", "--remove-all");
|
|
adb("forward", `tcp:${PORT}`, `localabstract:webview_devtools_remote_${pid2}`);
|
|
cdp = await CDP.attach(PORT);
|
|
await cdp.send("Runtime.enable");
|
|
await still(path.join(OUT, `${prefix}restart.png`));
|
|
const after = await cdp.evaluate(PLAYER);
|
|
const restored = !!before && !!after && after.title === before.title && after.status === "paused" && after.needs && Math.abs(after.pos - before.pos) <= 15;
|
|
if (!restored) problems.push("EXPECT restart " + JSON.stringify({ before, after }));
|
|
console.log((restored ? " ok " : " FAIL ") + "restart".padEnd(16) + " " + JSON.stringify({ before, after }));
|
|
await cdp.evaluate(`window.__ss && __ss.toggle()`);
|
|
await sleep(3500);
|
|
const resumed = await cdp.evaluate(PLAYER);
|
|
await still(path.join(OUT, `${prefix}resumed.png`));
|
|
const okResume = !!resumed && resumed.status === "playing" && !resumed.needs && (!after || resumed.pos >= after.pos - 1);
|
|
if (!okResume) problems.push("EXPECT resumed " + JSON.stringify(resumed));
|
|
console.log((okResume ? " ok " : " FAIL ") + "resumed".padEnd(16) + " " + JSON.stringify(resumed));
|
|
|
|
/* ── the media session, end to end ─────────────────────────
|
|
The system must hold a session for the app with the track
|
|
playing; a media key must reach the player through it; the
|
|
audio must keep moving with the app in the background. */
|
|
await sleep(1500);
|
|
const sess = mediaSessionState();
|
|
const sessOk = /com\.soulstream\.app/.test(sess.owner) && /PLAYING/.test(sess.state);
|
|
if (!sessOk) problems.push("EXPECT media-session " + JSON.stringify(sess));
|
|
console.log((sessOk ? " ok " : " FAIL ") + "media-session".padEnd(16) + " " + JSON.stringify(sess));
|
|
|
|
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PAUSE");
|
|
await sleep(1500);
|
|
const keyPaused = await cdp.evaluate(PLAYER);
|
|
adb("shell", "input", "keyevent", "KEYCODE_MEDIA_PLAY");
|
|
await sleep(2000);
|
|
const keyPlaying = await cdp.evaluate(PLAYER);
|
|
const keysOk = keyPaused?.status === "paused" && keyPlaying?.status === "playing";
|
|
if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying }));
|
|
console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status }));
|
|
|
|
const posBefore = (await cdp.evaluate(PLAYER))?.pos ?? 0;
|
|
cdp.close();
|
|
adb("shell", "input", "keyevent", "KEYCODE_HOME");
|
|
await sleep(10000);
|
|
const stillPlaying = /PLAYING/.test(mediaSessionState().state);
|
|
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
|
|
await sleep(2500);
|
|
cdp = await CDP.attach(PORT, { phone: true });
|
|
await cdp.send("Runtime.enable");
|
|
const posAfter = (await cdp.evaluate(PLAYER))?.pos ?? 0;
|
|
const bgOk = stillPlaying && posAfter >= posBefore + 8;
|
|
if (!bgOk) problems.push("EXPECT background " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
|
|
console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify({ posBefore, posAfter, stillPlaying }));
|
|
await still(path.join(OUT, `${prefix}shade.png`));
|
|
|
|
/* ── the network: a search, then a peer's copy played as it
|
|
arrives. Only with the app signed in (the stored account signs
|
|
in at boot); skipped, not failed, when it is not. ────────── */
|
|
const online = await cdp.evaluate(`!!(window.__ss && __ss.slsk && __ss.slsk.connection === 'online')`);
|
|
if (!online) {
|
|
console.log(" -- network skipped: not signed in on the phone");
|
|
} else {
|
|
const QUERY = flag("--query") || "daft punk";
|
|
await cdp.evaluate(HELPERS);
|
|
await cdp.evaluate(`__settle()`);
|
|
await sleep(700);
|
|
await cdp.evaluate(`document.querySelectorAll('.nav button')[1].click()`);
|
|
await sleep(600);
|
|
await cdp.evaluate(`(function () { var i = document.querySelector('.sfield input'); i.value = ${JSON.stringify(QUERY)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
|
const CAT = `(function () { return { songs: document.querySelectorAll('.cat-song').length, records: document.querySelectorAll('.rail .release').length, artists: document.querySelectorAll('.cat-artist').length }; })()`;
|
|
let catInfo = null;
|
|
for (let i = 0; i < 30; i++) {
|
|
await sleep(500);
|
|
catInfo = await cdp.evaluate(CAT);
|
|
if (catInfo.songs >= 1) break;
|
|
}
|
|
await sleep(600);
|
|
catInfo = await cdp.evaluate(CAT);
|
|
await still(path.join(OUT, `${prefix}cat-results.png`));
|
|
const resOk = catInfo.songs >= 1;
|
|
if (!resOk) problems.push("EXPECT cat-results " + JSON.stringify(catInfo));
|
|
console.log((resOk ? " ok " : " FAIL ") + "cat-results".padEnd(16) + " " + JSON.stringify(catInfo));
|
|
|
|
if (resOk) {
|
|
/* the hero's page: an artist's (tabs, Popular) or a record's */
|
|
const HERO = `(function () { var e = document.querySelector('.screen'); return { screen: e ? e.className : '', title: (document.querySelector('.gb-t') || {}).textContent || '', tabs: document.querySelectorAll('.disco-tabs button').length, pop: document.querySelectorAll('.row.pop').length, socs: document.querySelectorAll('.stats .soc').length, top: (document.querySelector('.top .top-name') || {}).textContent || '' }; })()`;
|
|
const topName = (await cdp.evaluate(HERO)).top;
|
|
await cdp.evaluate(HELPERS);
|
|
await cdp.evaluate(`__click('.top .btn-ghost', null)`);
|
|
/* the catalog page lands when the provider answers — polled, since a fixed wait read a slow answer as a failure */
|
|
let hero = null;
|
|
for (let i = 0; i < 24; i++) {
|
|
await sleep(500);
|
|
hero = await cdp.evaluate(HERO);
|
|
if (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1) break;
|
|
}
|
|
await still(path.join(OUT, `${prefix}cat-hero-page.png`));
|
|
const heroOk = hero.screen.includes("tr-push") && (!hero.screen.includes("has-hero") || hero.tabs >= 1 || hero.pop >= 1);
|
|
if (!heroOk) problems.push("EXPECT cat-hero-page " + JSON.stringify(hero));
|
|
console.log((heroOk ? " ok " : " FAIL ") + "cat-hero-page".padEnd(16) + " " + JSON.stringify({ top: topName, title: hero.title, tabs: hero.tabs, pop: hero.pop, socials: hero.socs, hero: hero.screen.includes("has-hero") }));
|
|
await cdp.evaluate(`history.back()`);
|
|
await sleep(1200);
|
|
|
|
/* the first song, through the resolver: the ladder, a
|
|
peer's copy, the lane rising before the file is here */
|
|
const STREAM = `(function () { var p = window.__ss && __ss.player; if (!p || !p.track) return null; return { title: p.track.title, from: p.track.stream && p.track.stream.username, status: p.status, phase: p.phase, pos: Math.round(p.position), coverage: +(p.coverage || 0).toFixed(2), received: p.stream && p.stream.received, total: p.stream && p.stream.total, failed: p.stream && p.stream.failed, error: p.error || '', live: (document.querySelector('.mini .m.live') || {}).textContent || '' }; })()`;
|
|
await cdp.evaluate(`document.querySelector('.cat-song').click()`);
|
|
let st = null;
|
|
for (let i = 0; i < 60; i++) {
|
|
await sleep(1000);
|
|
st = await cdp.evaluate(STREAM);
|
|
if (st && st.status === "playing" && st.pos >= 3) break;
|
|
if (st && (st.failed || st.error)) break;
|
|
}
|
|
await still(path.join(OUT, `${prefix}net-playing.png`));
|
|
console.log(" stream " + JSON.stringify(st));
|
|
await cdp.evaluate(`document.querySelector('.mini').click()`);
|
|
await sleep(2500);
|
|
await still(path.join(OUT, `${prefix}net-sheet.png`));
|
|
const st2 = await cdp.evaluate(STREAM);
|
|
const partial = !!st2 && st2.received > 0 && (st2.received < st2.total || st2.coverage < 1 || st2.phase === "rising");
|
|
const streamOk = !!st && !!st2 && st.status === "playing" && st2.status === "playing" && !!st2.from && st2.pos >= st.pos && !st2.failed && !st2.error;
|
|
if (!streamOk) problems.push("EXPECT net-stream " + JSON.stringify({ st, st2 }));
|
|
console.log((streamOk ? " ok " : " FAIL ") + "net-stream".padEnd(16) + " " + JSON.stringify({ from: st2?.from, status: st2?.status, phase: st2?.phase, pos: st2?.pos, coverage: st2?.coverage, received: st2?.received, total: st2?.total, live: st?.live, partialWhileWatched: partial }));
|
|
/* the raw peers, on request */
|
|
await cdp.evaluate(`document.querySelector('.sheet .np-head .iconbtn').click()`);
|
|
await sleep(700);
|
|
await cdp.evaluate(`document.querySelector('.net-ask').click()`);
|
|
const NET = `(function () { return { peers: document.querySelectorAll('.peer').length, files: document.querySelectorAll('.peer-file').length }; })()`;
|
|
let netInfo = null;
|
|
for (let i = 0; i < 30; i++) {
|
|
await sleep(500);
|
|
netInfo = await cdp.evaluate(NET);
|
|
if (netInfo.peers >= 4) break;
|
|
}
|
|
await still(path.join(OUT, `${prefix}net-peers.png`));
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* the lock-screen card: opt in, because it locks the phone and a
|
|
fingerprint has to open it again */
|
|
if (ARGS.includes("--lockscreen")) {
|
|
adb("shell", "input", "keyevent", "KEYCODE_SLEEP");
|
|
await sleep(2000);
|
|
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP");
|
|
await sleep(2500);
|
|
await still(path.join(OUT, `${prefix}lockscreen.png`));
|
|
console.log(" -- lockscreen still taken; the phone is locked now");
|
|
}
|
|
}
|
|
} finally {
|
|
if (cdp) cdp.close();
|
|
if (chrome) chrome.kill();
|
|
}
|
|
if (PHONE) {
|
|
const after = audioUnderruns();
|
|
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));
|
|
console.log("\n" + (real.length ? real.length + " problem(s):\n" + real.map((p) => " - " + p.slice(0, 400)).join("\n") : "no exceptions, no console errors, every step as expected"));
|
|
process.exit(real.length ? 1 : 0);
|
|
}
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|