Qualify real native music login and retained playback on mobile and desktop
This commit is contained in:
@@ -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
|
banner changes remain present. The current live V4V browser qualification follows
|
||||||
the first-visit intro and explicit native identity/event consent before checking
|
the first-visit intro and explicit native identity/event consent before checking
|
||||||
actual login success; earlier click timeouts are retained as failed test evidence.
|
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.
|
||||||
|
|||||||
@@ -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)));
|
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');
|
if (!local || !['http:', 'https:'].includes(parsed.protocol)) throw Error('Private node origin required');
|
||||||
parsed.port = '7475'; const appOrigin = parsed.origin;
|
parsed.port = '7475'; const appOrigin = parsed.origin;
|
||||||
const browser = await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
|
const ownBrowser = process.env.QUALIFICATION_LAUNCH_BROWSER === '1';
|
||||||
for (const width of [390, 1440]) {
|
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 context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: 'block' });
|
||||||
const proof = { width, signatures: 0, login: [], blocked: false };
|
const proof = { width, signatures: 0, login: [], blocked: false };
|
||||||
const challenges = new Set();
|
const challenges = new Set();
|
||||||
|
let page;
|
||||||
|
let step = 'load dashboard';
|
||||||
try {
|
try {
|
||||||
const cookies = JSON.parse(fs.readFileSync(process.env.QUALIFICATION_COOKIES, 'utf8'));
|
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' })));
|
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();
|
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()); });
|
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.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 });
|
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'));
|
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 frame = page.frameLocator('iframe[src*="7475"]');
|
||||||
const signIn = frame.getByRole('button', { name: 'Sign in with Nostr', exact: true });
|
const signIn = frame.getByRole('button', { name: 'Sign in with Nostr', exact: true });
|
||||||
await expect(signIn).toBeVisible({ timeout: 45000 });
|
await expect(signIn).toBeVisible({ timeout: 45000 });
|
||||||
await expect(frame.locator('input[type=password]')).toHaveCount(0);
|
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();
|
await signIn.click();
|
||||||
const deadline = Date.now() + 45000; let approvals = 0;
|
const deadline = Date.now() + 45000; let approvals = 0;
|
||||||
while (Date.now() < deadline && !proof.login.includes(200)) {
|
while (Date.now() < deadline && !proof.login.includes(200)) {
|
||||||
if (proof.blocked) throw Error('Unexpected signing or payment request blocked');
|
if (proof.blocked) throw Error('Unexpected signing or payment request blocked');
|
||||||
for (const name of ['Authenticate', 'Approve']) {
|
for (const name of ['Authenticate', 'Approve']) {
|
||||||
const button = page.getByRole('button', { name, exact: true });
|
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);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
expect(proof.blocked).toBe(false); expect(proof.signatures).toBeGreaterThan(0); expect(proof.login).toContain(200);
|
expect(proof.blocked).toBe(false); expect(proof.signatures).toBeGreaterThan(0); expect(proof.login).toContain(200);
|
||||||
await expect(signIn).toHaveCount(0, { timeout: 15000 });
|
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);
|
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' }));
|
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 { 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); });
|
})().then(() => process.exit(0)).catch(error => { console.error(String(error.message).split(/Call [Ll]og:/)[0]); process.exit(1); });
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
|
|||||||
await app.waitForFunction(() => window.PulsewireLegacy?.getReactBridge?.(), undefined, { timeout: 30000 });
|
await app.waitForFunction(() => window.PulsewireLegacy?.getReactBridge?.(), undefined, { timeout: 30000 });
|
||||||
const later = app.getByRole('button', { name: 'Not now', exact: true });
|
const later = app.getByRole('button', { name: 'Not now', exact: true });
|
||||||
if (await later.isVisible().catch(() => false)) await later.click();
|
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 tracks = await app.evaluate(() => {
|
||||||
const bridge = window.PulsewireLegacy.getReactBridge();
|
const bridge = window.PulsewireLegacy.getReactBridge();
|
||||||
return bridge.getState().releases.filter(release => {
|
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 });
|
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);
|
const initial = await app.evaluate(() => window.__qualificationMedia.find(audio => !audio.paused).currentTime);
|
||||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
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');
|
const bar = page.locator('.audio-player-bar');
|
||||||
await expect(bar).toBeVisible();
|
await expect(bar).toBeVisible();
|
||||||
await expect(bar).toContainText(tracks[0].title);
|
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 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 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 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);
|
await expect(bar).toContainText(tracks[1].title);
|
||||||
// Previous after >3 seconds intentionally restarts the current song in V4V.
|
// 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 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 page.getByRole('button', { name: 'Shuffle', exact: true }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
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);
|
expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user