Qualify real native music login and retained playback on mobile and desktop

This commit is contained in:
archipelago
2026-10-06 20:13:08 -04:00
parent 5d66d2758d
commit 057150d377
3 changed files with 67 additions and 10 deletions
+21
View File
@@ -256,3 +256,24 @@ also includes the deployed Lightning retry compatibility fix; native player and
banner changes remain present. The current live V4V browser qualification follows
the first-visit intro and explicit native identity/event consent before checking
actual login success; earlier click timeouts are retained as failed test evidence.
### Real native login and retained playback qualified
The privately deployed managed app passed real dashboard browser qualification at
390px and 1440px. Each fresh session completed the app intro, native identity
selection and explicit consent for one authentication event, then received HTTP
200 from the app login endpoint. The alpha password field was absent.
Two existing bundled demo tracks were used with muted output and payment requests
blocked by the qualification harness. Closing the app retained playing audio and
showed the native bar. Pause/play, next, previous and shuffle controlled the app's
own player, track artwork matched, and reopening retained the same iframe and
playback position. No payment or publication was performed.
Evidence: `/tmp/archy-v4v-native-live-390-catalog-ready.log` and
`/tmp/archy-v4v-native-live-1440.log`. Earlier failures exposed harness assumptions:
the intro blocked login, outgoing consent controls remained visible during their
leave animation, and the song catalog loaded after bridge initialization. Hidden
iframe checks now use interval polling because animation-frame polling stops
when hidden. Timeouts were not increased. These browser passes do not establish
physical companion acceptance or replace the remaining lifecycle checks.
+30 -4
View File
@@ -9,11 +9,16 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neo
const local = parsed.hostname === 'localhost' || (octets.length === 4 && octets.every(n => Number.isInteger(n) && n >= 0 && n <= 255) && (octets[0] === 127 || octets[0] === 10 || (octets[0] === 192 && octets[1] === 168) || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)));
if (!local || !['http:', 'https:'].includes(parsed.protocol)) throw Error('Private node origin required');
parsed.port = '7475'; const appOrigin = parsed.origin;
const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
for (const width of [390, 1440]) {
const ownBrowser = process.env.QUALIFICATION_LAUNCH_BROWSER === '1';
const browser = ownBrowser ? await chromium.launch({ headless: true })
: await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
try {
for (const width of process.env.TEST_WIDTH ? [Number(process.env.TEST_WIDTH)] : [390, 1440]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
const proof = { width, signatures: 0, login: [], blocked: false };
const challenges = new Set();
let page;
let step = 'load dashboard';
try {
const cookies = JSON.parse(fs.readFileSync(process.env.QUALIFICATION_COOKIES, 'utf8'));
await context.addCookies(Object.entries(cookies).map(([name, value]) => ({ name, value, url: origin, httpOnly: name !== 'csrf_token' })));
@@ -58,29 +63,50 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neo
return route.fallback();
});
}
const page = await context.newPage();
page = await context.newPage();
page.on('response', response => { const u = new URL(response.url()); if (u.origin === appOrigin && u.pathname === '/api/auth/nostr/login') proof.login.push(response.status()); });
await page.goto(origin + '/dashboard/discover', { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(() => document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'), undefined, { timeout: 30000 });
step = 'open managed app';
await page.evaluate(() => document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').openSession('node-demo-v4v'));
const frame = page.frameLocator('iframe[src*="7475"]');
const signIn = frame.getByRole('button', { name: 'Sign in with Nostr', exact: true });
await expect(signIn).toBeVisible({ timeout: 45000 });
await expect(frame.locator('input[type=password]')).toHaveCount(0);
// A fresh browser gets the real app introduction before authentication.
// Drive its visible controls instead of clicking through the overlay.
const introPlay = frame.getByRole('button', { name: 'Play intro', exact: true });
const introSkip = frame.getByRole('button', { name: 'Skip intro', exact: true });
step = 'wait for app introduction';
await expect(introPlay).toBeVisible();
step = 'play app introduction';
await introPlay.click();
step = 'skip app introduction';
await expect(introSkip).toBeVisible();
await introSkip.click();
step = 'click native Nostr sign-in';
await signIn.click();
const deadline = Date.now() + 45000; let approvals = 0;
while (Date.now() < deadline && !proof.login.includes(200)) {
if (proof.blocked) throw Error('Unexpected signing or payment request blocked');
for (const name of ['Authenticate', 'Approve']) {
const button = page.getByRole('button', { name, exact: true });
if (await button.isVisible().catch(() => false)) { if (++approvals > 6) throw Error('Repeated consent loop'); await button.click(); }
if (await button.isVisible().catch(() => false)) { if (++approvals > 6) throw Error('Repeated consent loop'); step = 'native consent: ' + name; await button.click(); await expect(button).toBeHidden(); }
}
await page.waitForTimeout(200);
}
expect(proof.blocked).toBe(false); expect(proof.signatures).toBeGreaterThan(0); expect(proof.login).toContain(200);
await expect(signIn).toHaveCount(0, { timeout: 15000 });
step = 'native playback controls';
if (process.env.QUALIFY_PLAYBACK === '1') await require('./v4v-native-playback.cjs')(page, appOrigin, proof);
console.log(JSON.stringify({ ...proof, result: 'PASS', scope: process.env.QUALIFY_PLAYBACK === '1' ? 'real managed V4V native login and bundled-song playback' : 'real managed V4V native authentication only; playback remains separately qualified' }));
} catch (error) {
const prefix = '/tmp/archy-v4v-native-failure-' + width;
fs.writeFileSync(prefix + '.private.txt', String(error.stack || error), { mode: 0o600 });
if (page) await page.screenshot({ path: prefix + '.png' }).then(() => fs.chmodSync(prefix + '.png', 0o600)).catch(() => {});
console.error(JSON.stringify({ width, failedStep: step, signatures: proof.signatures, loginStatuses: proof.login }));
throw error;
} finally { await context.unrouteAll({ behavior: 'ignoreErrors' }).catch(() => {}); await context.close(); }
}
} finally { if (ownBrowser) await browser.close(); }
})().then(() => process.exit(0)).catch(error => { console.error(String(error.message).split(/Call [Ll]og:/)[0]); process.exit(1); });
+16 -6
View File
@@ -7,6 +7,14 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
await app.waitForFunction(() => window.PulsewireLegacy?.getReactBridge?.(), undefined, { timeout: 30000 });
const later = app.getByRole('button', { name: 'Not now', exact: true });
if (await later.isVisible().catch(() => false)) await later.click();
await app.waitForFunction(() => {
const bridge = window.PulsewireLegacy.getReactBridge();
return bridge.getState().releases.filter(release => {
try { const url = new URL(bridge.helpers.releasePlayableUrl(release), location.href);
return url.origin === location.origin && url.pathname.startsWith('/media/dev-audio/');
} catch { return false; }
}).length >= 2;
}, undefined, { timeout: 30000 });
const tracks = await app.evaluate(() => {
const bridge = window.PulsewireLegacy.getReactBridge();
return bridge.getState().releases.filter(release => {
@@ -27,21 +35,23 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
await app.waitForFunction(() => window.__qualificationMedia?.some(audio => !audio.paused && audio.readyState >= 2 && audio.currentTime > 0), undefined, { timeout: 30000 });
const initial = await app.evaluate(() => window.__qualificationMedia.find(audio => !audio.paused).currentTime);
await page.getByRole('button', { name: 'Close', exact: true }).click();
const backupLater = page.getByRole('button', { name: 'Later', exact: true });
if (await backupLater.isVisible().catch(() => false)) await backupLater.click();
const bar = page.locator('.audio-player-bar');
await expect(bar).toBeVisible();
await expect(bar).toContainText(tracks[0].title);
await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000 });
await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000, polling: 100 });
await page.getByRole('button', { name: 'Pause audio', exact: true }).click();
await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused));
await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Play audio', exact: true }).click();
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused));
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Next track', exact: true }).click();
await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id);
await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id, { polling: 100 });
await expect(bar).toContainText(tracks[1].title);
// Previous after >3 seconds intentionally restarts the current song in V4V.
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4));
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Previous track', exact: true }).click();
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3));
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3), undefined, { polling: 100 });
await page.getByRole('button', { name: 'Shuffle', exact: true }).click();
await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true');
expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true);