const { test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const { chromium } = require('playwright'); let server, browser, base; before(async () => { server = spawn('bun', ['tests/fixtures/party-server.mjs'], { cwd: require('node:path').resolve(__dirname, '..'), stdio: ['ignore','pipe','pipe'] }); base = await new Promise((resolve, reject) => { server.stdout.once('data', x => resolve(String(x).trim())); server.once('error', reject); server.once('exit', code => reject(new Error(`server exit ${code}`))); }); browser = await chromium.launch({ headless: true }); }); after(async () => { await browser?.close(); server?.kill(); }); async function host(viewport = { width: 1280, height: 900 }) { const context = await browser.newContext({ viewport }); const page = await context.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.goto(base); await page.click('#navPartyBtn'); await page.fill('.pt-name', 'Alex'); await page.click('[data-act=start]'); await page.waitForFunction(() => /^[A-Z0-9]{6}$/.test(document.querySelector('#ptRoomCode')?.textContent)); return { context, page, errors, code: await page.locator('#ptRoomCode').textContent() }; } async function guest(code) { const context = await browser.newContext(); const page = await context.newPage(); await page.goto(base); await page.evaluate(code => YT.Party._join(code, 'Sam'), code); await page.waitForFunction(() => YT.Party.state().members === 2); return { context, page }; } test('host transport stays available, drafts survive membership updates, chat and room controls work', async () => { const h = await host(); await h.page.evaluate(() => { current = { meta: { id: 'demo', title: 'Evening session' } }; YT.Party.afterLoad(); }); await h.page.fill('.pt-msg', 'Draft that must survive'); const g = await guest(h.code); await h.page.waitForFunction(() => YT.Party.state().members === 2); assert.equal(await h.page.inputValue('.pt-msg'), 'Draft that must survive'); await h.page.click('[data-act=playback]'); assert.equal(await h.page.evaluate(() => Player.master.paused), false); await h.page.click('[data-act=forward10]'); assert.equal(await h.page.evaluate(() => Player.master.currentTime), 10); await h.page.click('[data-act=next]'); assert.equal(await h.page.evaluate(() => nextCount), 1); await h.page.locator('.pt-send button').click(); await g.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Draft that must survive')); await h.page.click('[data-tab=room]'); assert.equal(await h.page.locator('[data-act=playback]').isVisible(), true); await h.page.check('[data-act=audio]'); assert.equal(await h.page.evaluate(() => data.settings.audioOnly), true); await h.page.uncheck('[data-act=gentle]'); assert.equal(await h.page.evaluate(() => localStorage.getItem('ytpPartyGentle')), 'false'); assert.deepEqual(h.errors, []); await g.context.close(); await h.context.close(); }); test('buffering guest does not repeatedly seek; ready playback recovers and cooldown prevents thrashing', async () => { const h = await host(); await h.page.evaluate(() => { current = { meta: { id: 'demo', title: 'Evening session' } }; Player.master.paused = false; YT.Party.afterLoad(); }); const g = await guest(h.code); await g.page.waitForFunction(() => current?.meta?.id === 'demo'); await g.page.evaluate(() => { Player.master.readyState = 2; Player.master.currentTime = 0; seekCount = 0; }); await h.page.evaluate(() => { Player.master.currentTime = 60; YT.Party.afterLoad(); }); await g.page.waitForFunction(() => document.querySelector('#ptStatus').textContent.includes('Buffering')); assert.equal(await g.page.evaluate(() => seekCount), 0); await g.page.evaluate(() => { Player.master.readyState = 4; }); await h.page.evaluate(() => YT.Party.afterLoad()); await g.page.waitForFunction(() => seekCount === 1); await h.page.evaluate(() => { Player.master.currentTime = 90; YT.Party.afterLoad(); }); await g.page.waitForTimeout(150); assert.equal(await g.page.evaluate(() => seekCount), 1); await g.context.close(); await h.context.close(); }); test('membership preserves input node; mobile panel and host controls fit above navigation', async () => { const h = await host({ width: 390, height: 844 }); await h.page.fill('.pt-msg', 'Still typing'); await h.page.evaluate(() => { window.draftNode = document.querySelector('.pt-msg'); }); const g = await guest(h.code); assert.equal(await h.page.evaluate(() => draftNode === document.querySelector('.pt-msg')), true); await h.page.click('[data-tab=queue]'); await h.page.click('[data-tab=room]'); const fit = await h.page.evaluate(() => { const panel = document.querySelector('#partyPanel').getBoundingClientRect(); const controls = document.querySelector('.pt-transport').getBoundingClientRect(); return panel.left >= 0 && panel.right <= innerWidth && panel.bottom <= innerHeight - 96 && controls.bottom <= panel.bottom; }); assert.equal(fit, true); await h.page.setViewportSize({width:320,height:568}); await h.page.click('[data-tab=chat]'); assert.equal(await h.page.locator('.pt-send').evaluate(e => e.getBoundingClientRect().bottom <= document.querySelector('#partyPanel').getBoundingClientRect().bottom), true); await h.page.click('#ptMin'); assert.equal(await h.page.locator('#ptMin').isVisible(), true); assert.equal(await h.page.locator('#partyPanel').evaluate(e => e.getBoundingClientRect().bottom <= innerHeight - 96), true); await h.page.click('#ptMin'); assert.deepEqual(h.errors, []); await g.context.close(); await h.context.close(); }); test('queue votes preserve search, chat preserves history position, reconnect keeps drafts', async () => { const h = await host(); const g = await guest(h.code); await h.page.click('[data-tab=queue]'); await h.page.locator('.pt-dj input[type=checkbox]').check(); await h.page.locator('.party-dj-search input').fill('Evening'); await h.page.locator('.party-dj-search button').click(); await h.page.getByRole('button', {name:'Suggest: Evening session', exact:true}).click(); await h.page.waitForFunction(() => document.querySelector('.party-dj-row')); assert.equal(await h.page.inputValue('.party-dj-search input'), 'Evening'); await h.page.click('[data-tab=chat]'); for (let i = 0; i < 12; i++) { await g.page.fill('.pt-msg', `Message ${i}: This is a conversation to test reading earlier messages.`); await g.page.locator('.pt-send button').click(); await g.page.waitForTimeout(410); } await h.page.evaluate(() => { document.querySelector('#ptChat').scrollTop = 0; }); await g.page.fill('.pt-msg', 'A new message while you read'); await g.page.locator('.pt-send button').click(); await h.page.waitForFunction(() => !document.querySelector('.pt-new').hidden); assert.equal(await h.page.locator('#ptChat').evaluate(e => e.scrollTop), 0); await h.page.click('[data-act=latest]'); assert.equal(await h.page.locator('.pt-new').isVisible(), false); await h.page.fill('.pt-msg', 'Keep this offline draft'); await h.context.setOffline(true); await h.page.locator('.pt-send button').click(); assert.equal(await h.page.inputValue('.pt-msg'), 'Keep this offline draft'); await h.context.setOffline(false); await h.page.waitForFunction(() => document.querySelector('#ptStatus').textContent.includes('hosting')); assert.equal(await h.page.inputValue('.pt-msg'), 'Keep this offline draft'); assert.deepEqual(h.errors, []); await g.context.close(); await h.context.close(); }); test('heartbeat measures round trip and screenshots cover desktop and mobile host controls', async () => { const h = await host(); await h.page.evaluate(() => { current = { meta: { id:'demo', title:'Evening session ยท acoustic favorites' } }; Player.master.paused = false; YT.Party.afterLoad(); }); const g = await guest(h.code); await g.page.fill('.pt-msg', 'Ready when you are!'); await g.page.locator('.pt-send button').click(); await h.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Ready when you are')); await h.page.click('[data-tab=room]'); await h.page.waitForFunction(() => document.querySelector('#ptLatency').textContent.includes('ms round trip')); await h.page.click('[data-tab=chat]'); if (process.env.PARTY_SCREENSHOTS) { const fs = require('node:fs'); fs.mkdirSync(process.env.PARTY_SCREENSHOTS, {recursive:true}); await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/desktop.png`}); await h.page.setViewportSize({width:390,height:844}); await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/mobile.png`}); await h.page.click('[data-tab=room]'); await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/mobile-room.png`}); } assert.deepEqual(h.errors, []); await g.context.close(); await h.context.close(); }); test('independent guest reconnects chat without resuming host playback', async () => { const h = await host(); const g = await guest(h.code); await g.page.waitForTimeout(1600); await g.page.click('.pt-msg'); await g.page.evaluate(() => { Player.master.paused = true; Player.master.dispatchEvent(new Event('pause')); }); assert.equal(await g.page.evaluate(() => YT.Party.state().detached), true); await g.context.setOffline(true); await g.context.setOffline(false); await g.page.waitForFunction(() => document.querySelector('#ptStatus').textContent === 'Watching independently'); assert.equal(await g.page.evaluate(() => YT.Party.state().detached), true); await g.page.fill('.pt-msg', 'Still here, watching independently'); await g.page.locator('.pt-send button').click(); await h.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Still here, watching independently')); await g.context.close(); await h.context.close(); });