Watch party refresh: Chat/Queue/Room tabs, host transport, gentle sync, heartbeat and latency-aware reconnect, in-place section updates
This commit is contained in:
28
tests/fixtures/party-fixture.js
vendored
Normal file
28
tests/fixtures/party-fixture.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
if (document.currentScript.src.includes('?boot')) {
|
||||
window.YT.Party.boot();
|
||||
document.getElementById('navPartyBtn').onclick = () => window.YT.Party.open();
|
||||
} else {
|
||||
Object.assign(window, {
|
||||
WEB: true, $: id => document.getElementById(id),
|
||||
data: { settings: {}, profile: null }, current: null, cachedIds: new Set(),
|
||||
DirectMedia: { attach() {}, enabled: () => false, handle() {} },
|
||||
Lazy: { load: () => Promise.resolve(), loaded: () => true },
|
||||
Notes: { stageLyrics: { on: () => false, set() {} } },
|
||||
API: { search: async () => ({ results: [{ id: 'demo-video1', title: 'Evening session' }] }) },
|
||||
toast: text => { window.lastToast = text; }, escapeHtml: s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
||||
fmtTime: String, updatePlayBtn() {}, advanceQueue() {},
|
||||
TransportIcons: { svg: () => '' }, seekCount: 0, nextCount: 0,
|
||||
});
|
||||
window.els = { video: document.createElement('video'), audio: document.createElement('audio'), speed: { value: '1' }, audioOnlyToggle: document.createElement('input') };
|
||||
window.Player = {
|
||||
master: Object.assign(new EventTarget(), { currentTime: 0, paused: true, playbackRate: 1, readyState: 4, seeking: false, duration: 3600 }),
|
||||
play() { this.master.paused = false; }, pause() { this.master.paused = true; },
|
||||
toggle() { this.master.paused = !this.master.paused; },
|
||||
seek(t) { this.master.currentTime = t; window.seekCount++; }, applySpeed() { this.master.playbackRate = Number(els.speed.value); },
|
||||
};
|
||||
els.video = Player.master;
|
||||
window.playVideoAt = async (v, t = 0) => { window.current = { meta: v }; Player.master.currentTime = t; Player.master.paused = false; };
|
||||
window.playNext = () => { window.nextCount++; };
|
||||
window.playPrev = () => {};
|
||||
els.audioOnlyToggle.addEventListener('change', () => { data.settings.audioOnly = els.audioOnlyToggle.checked; });
|
||||
}
|
||||
16
tests/fixtures/party-server.mjs
vendored
Normal file
16
tests/fixtures/party-server.mjs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
// Isolated integration harness: real party relay and frontend chunk; deterministic player.
|
||||
import { createPartyHub } from '../../server/party.js';
|
||||
import { join } from 'node:path';
|
||||
const root = new URL('../../frontend/', import.meta.url).pathname;
|
||||
const hub = createPartyHub();
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: Number(process.env.PARTY_TEST_PORT || 0),
|
||||
async fetch(req, srv) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/ws/party') return hub.upgrade(req, srv, '127.0.0.1');
|
||||
if (path === '/') return new Response(await Bun.file(new URL('party.html', import.meta.url)).text(), { headers: { 'Content-Type': 'text/html' } });
|
||||
if (path === '/party-fixture.js') return new Response(Bun.file(new URL('party-fixture.js', import.meta.url)));
|
||||
if (path.includes('..')) return new Response('', {status: 400});
|
||||
return new Response(Bun.file(join(root, path)));
|
||||
}, websocket: hub.websocket,
|
||||
});
|
||||
console.log(`http://127.0.0.1:${server.port}`);
|
||||
4
tests/fixtures/party.html
vendored
Normal file
4
tests/fixtures/party.html
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="en" data-layout="classic"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Watch party integration fixture</title><link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/theme-classic.css"></head><body>
|
||||
<main style="padding:32px;max-width:600px"><h1>Watch party</h1><p>Integration preview · deterministic playback fixture</p><button id="navPartyBtn"><span class="nav-label">Watch party</span></button></main>
|
||||
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party"><div class="pt-head"><span id="ptTitle">Watch party</span><span id="ptStatus" class="pt-status" role="status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div><div class="pt-body"></div></aside>
|
||||
<script src="/party-fixture.js"></script><script src="/party-dj.js"></script><script src="/watch-party-client.js"></script><script src="/party-fixture.js?boot"></script></body></html>
|
||||
147
tests/watch-party-browser.cjs
Normal file
147
tests/watch-party-browser.cjs
Normal file
@@ -0,0 +1,147 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user