The desktop's two playlist pages on the phone: the shelf (pinned first, mosaic covers, badges, New playlist, Import) and the page (kind, Pinned chip, description, the meta line's warnings, Play / Shuffle / more, a filter, rows tagged unresolved / set audio / file missing). Sheets for a card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm; Add to playlist on every track and record sheet; a removal undoable for seven seconds from the neutral toast (notice.svelte.js); stream rows play through the resolver as path-less entries, a set slice starts and ends at its cues; Add to library / Download all tracks with the desktop's bulk rules; nav.replace for a review that became the thing it made. The import doors that need no file — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the review screen and the desktop's catalog verification pass. A share plugin crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps the text in a ShareActivity and the WebView hands it to the import when it comes back to the front. You → Sources: the source-quality window and the priority per mode. Found by the walks and fixed: set_settings takes camelCase arguments, so the phone's normalization and library-folder saves never landed; Cover.svelte's non-eager wrapper has no layout box, so its intersection observer never fired and row thumbnails never loaded (the Artists tab too); a history push while a history.go was still in flight left the nav mirror one entry ahead and the page blank after a confirmed delete — the mirror now applies once per microtask and never pushes during a pending traversal. The harness names unmocked commands, carries console warnings' frames (the smoke bundle is built unminified), probes the history mirror on every step, mocks app_status / play_local / waveform / lyrics_get / player_state and applies settings patches, runs adb steps on the phone (the share intent), and walks playlists, the imports, the share and the settings on both the mock and the phone. ANDROID.md documents all of it.
790 lines
58 KiB
JavaScript
790 lines
58 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(); }
|
|
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'),
|
|
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 || '',
|
|
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,
|
|
};
|
|
})()`;
|
|
|
|
/* ── 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: `document.querySelector('.scrim').click()`, wait: 500 },
|
|
{ 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 },
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
|
|
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
|
|
{ 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 },
|
|
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 600 },
|
|
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 900 },
|
|
{ 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 },
|
|
/* the fake network: a room with a few lines in it */
|
|
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
|
|
{ do: `history.back()`, wait: 700 },
|
|
/* 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: `__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 },
|
|
/* 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 },
|
|
{ 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 },
|
|
/* 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 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "playlist-more", wait: 700 },
|
|
{ do: `__click('.halfsheet .row', 'Unpin')`, name: "playlist-unpinned", wait: 900 },
|
|
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, wait: 600 },
|
|
{ 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: `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"),
|
|
"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",
|
|
"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"),
|
|
"search-empty": (i) => i.title.startsWith("Search") && !i.half.includes("up"),
|
|
search: (i) => i.title.startsWith("Search"),
|
|
library: (i) => i.title.startsWith("Your library"),
|
|
album: (i) => i.screen.includes("tr-push") && !i.mini,
|
|
"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"),
|
|
"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,
|
|
/* 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-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"),
|
|
"cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
|
|
"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),
|
|
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,
|
|
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-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-more": (i) => i.half.includes("up") && /Unpin/.test(i.halfText) && /Rename/.test(i.halfText) && /Delete playlist/.test(i.halfText),
|
|
"playlist-unpinned": (i) => !i.half.includes("up") && !/Pinned/.test(i.plkind),
|
|
"playlist-rename": (i) => i.half.includes("up") && i.promptVal === "Late night drive",
|
|
"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.querySelectorAll('.fbtn')[2].click()`, name: "queue", wait: 800 },
|
|
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "lyrics", wait: 3500 },
|
|
{ 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'; 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 },
|
|
{ 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.querySelectorAll('.nav button')[2].click()`, wait: 700 },
|
|
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
|
|
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 },
|
|
{ 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,
|
|
"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"),
|
|
"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,
|
|
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 === 2 && !i.toast,
|
|
"import-created": (i) => i.title.startsWith("Imported playlist") && i.plrows === 2 && !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. */
|
|
function audioUnderruns() {
|
|
try {
|
|
const dump = adb("shell", "dumpsys", "media.audio_flinger").toString();
|
|
let n = 0;
|
|
for (const m of dump.matchAll(/Fifo frame underruns:[^\n]*n:\s*(\d+)/g)) n += +m[1];
|
|
for (const m of dump.matchAll(/numTracks=\d+ writeErrors=\d+ underruns=(\d+)/g)) n += +m[1];
|
|
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 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;
|
|
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 || "")); };
|
|
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.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();
|
|
adb("shell", "am", "force-stop", "com.soulstream.app");
|
|
await sleep(800);
|
|
adb("shell", "am", "start", "-n", "com.soulstream.app/.MainActivity");
|
|
/* 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);
|
|
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)`);
|
|
await sleep(4500);
|
|
const hero = await cdp.evaluate(HERO);
|
|
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));
|
|
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 && underrunsBefore != null) {
|
|
const after = audioUnderruns();
|
|
const grew = after != null && after > underrunsBefore;
|
|
if (grew) problems.push(`AUDIO underruns rose during the walk: ${underrunsBefore} → ${after}`);
|
|
console.log((grew ? " FAIL " : " ok ") + "audio".padEnd(16) + ` mixer underruns ${underrunsBefore} → ${after}`);
|
|
}
|
|
/* 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); });
|