import { chromium } from '@playwright/test'; import fs from 'fs'; import { createHmac } from 'crypto'; export const SP = '/tmp/claude-0/-root-code/cbccb48a-37cc-4dbe-ba9c-7430b3884e5a/scratchpad'; export const BASE = 'http://127.0.0.1:5173', HS = 'http://localhost:8008'; export const room = fs.readFileSync(`${SP}/callroom.txt`, 'utf8').trim(); export const enc = encodeURIComponent; export const api = async (m, p, t, b) => (await fetch(`${HS}${p}`, { method: m, headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' }, body: b ? JSON.stringify(b) : undefined })).json(); export const login = async (u) => (await api('POST', '/_matrix/client/v3/login', '', { type: 'm.login.password', identifier: { type: 'm.id.user', user: u }, password: 'password123' })).access_token; export const members = async (tok) => (await api('GET', `/_matrix/client/v3/rooms/${enc(room)}/state`, tok)).filter((e) => e.type === 'org.matrix.msc3401.call.member' && e.content && Object.keys(e.content).length).map((e) => e.state_key); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); export const adminJwt = () => { const h = b64({ alg: 'HS256', typ: 'JWT' }), p = b64({ iss: 'devkey', sub: 'admin', exp: Math.floor(Date.now() / 1000) + 600, video: { roomAdmin: true, roomList: true, room } }); return `${h}.${p}.${createHmac('sha256', 'devsecretdevsecretdevsecretdevsecret').update(`${h}.${p}`).digest('base64url')}`; }; export const sfuParticipants = async () => { const r = await (await fetch('http://127.0.0.1:7880/twirp/livekit.RoomService/ListParticipants', { method: 'POST', headers: { Authorization: 'Bearer ' + adminJwt(), 'Content-Type': 'application/json' }, body: JSON.stringify({ room }) })).json(); return (r.participants || []).map((p) => ({ identity: p.identity, tracks: (p.tracks || []).map((t) => `${t.type}/${t.source}${t.muted ? '(muted)' : ''}`) })); }; export const launch = (extra = []) => chromium.launch({ args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--auto-select-desktop-capture-source=Entire screen', `--use-file-for-fake-audio-capture=${SP}/pw/tone.wav`, ...extra] }); // records io.lotus.* widget messages (both directions) + oscillator starts (join/leave chimes) export const INIT = ` window.__lotus = []; window.__osc = []; window.addEventListener('message', (e) => { const d = e.data; if (d && typeof d.action === 'string' && d.action.startsWith('io.lotus.')) window.__lotus.push({ t: Date.now(), api: d.api, action: d.action, data: d.data, response: d.response }); }); const os = OscillatorNode.prototype.start; OscillatorNode.prototype.start = function (w) { window.__osc.push({ t: Date.now(), f: Math.round(this.frequency.value), w: w ?? 0 }); return os.call(this, w); }; `; export const newUser = async (browser, user, { width = 1300, height = 850 } = {}) => { const ctx = await browser.newContext({ viewport: { width, height }, permissions: ['microphone', 'camera'], ignoreHTTPSErrors: true }); await ctx.addInitScript(INIT); const page = await ctx.newPage(); page.on('pageerror', (e) => console.log(`[${user} pageerror]`, e.message)); await page.goto(`${BASE}/login/${enc(HS)}/`); await page.getByLabel('Username or email').fill(user); await page.getByLabel('Password', { exact: true }).fill('password123'); await page.getByRole('button', { name: 'Login' }).click(); await page.waitForURL(/\/home/, { timeout: 60000 }); return { ctx, page }; }; export const joinCall = async (page, r = room, wait = 8000) => { await page.goto(`${BASE}/home/${enc(r)}`); await page.waitForTimeout(3500); await page.getByRole('button', { name: /^Join/i }).first().click(); await page.waitForTimeout(wait); }; export const lotusMsgs = (page) => page.evaluate(() => window.__lotus); export const ecFrame = (page) => page.frames().find((f) => f.url().includes('element-call')); export const ecMsgs = async (page) => { const f = ecFrame(page); return f ? f.evaluate(() => window.__lotus) : []; }; export const oscs = (page) => page.evaluate(() => window.__osc); export const chimes = (list) => { // classify oscillator bursts: join = 587 then 880, leave = 880 then 587 const out = []; const sorted = [...list].sort((a, b) => a.w - b.w); for (let i = 0; i < sorted.length; i += 1) { const a = sorted[i], b = sorted[i + 1]; if (b && Math.abs(b.w - a.w - 0.1) < 0.03) { out.push(a.f === 587 && b.f === 880 ? 'join' : a.f === 880 && b.f === 587 ? 'leave' : `?${a.f}>${b.f}`); i += 1; } else out.push(`single${a.f}`); } return out; }; export const text = (page) => page.evaluate(() => document.body.innerText); export const leaveCall = async (page) => { await page.getByRole('button', { name: /^End/ }).first().click().catch(() => {}); await page.waitForTimeout(2500); };