#!/usr/bin/env node /** * Pictures of the app, for comparing a change's before and after. * * Launches the built app against a throwaway home (see lib/harness.mjs - no * real account, no shared daemon), gives it an IRC network to show from a * small fake server, and photographs a fixed list of scenes. Discord-shaped * screens that no fake server can produce - a guild's channels, a voice * call, reactions - are filled in through the test-only state hook main * installs when started with MOHO_UI_SHOTS=1. * * node scripts/ui-shots.mjs --out before every scene * node scripts/ui-shots.mjs --out after --only rail scenes whose name holds "rail" * node scripts/ui-shots.mjs --compare before after writes compare.html * * Builds nothing: run `npx electron-vite build` first (and have the daemon * built). Pictures go to ~/.local/share/moho-dev/ui-shots//, or * $MOHO_UI_SHOTS_DIR; motion is recorded as short mp4s with ffmpeg. */ import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { at, click, fakeIrc, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs' const SIZE = { width: 1280, height: 800 } const DIR = process.env.MOHO_UI_SHOTS_DIR ?? path.join(os.homedir(), '.local/share/moho-dev/ui-shots') const NICK = 'salastil' const PEOPLE = ['alice', 'bob', 'carol', 'dave', 'eve_the_longer_named', 'frank'] // --- what the fake network says --------------------------------------------- const DAY = 24 * 3600 * 1000 /** Midnight today, local time: history is placed relative to it. */ const today = (() => { const d = new Date() d.setHours(0, 0, 0, 0) return d.getTime() })() const when = (daysAgo, hh, mm) => new Date(today - daysAgo * DAY + (hh * 60 + mm) * 60000) const HISTORY = { '#general': [ [3, 9, 12, 'alice', 'morning all - anyone tried the new build?'], [3, 9, 13, 'bob', 'yeah, the voice stuff is way better than last week'], [3, 9, 15, 'alice', 'nice, I will grab it tonight'], [1, 18, 40, 'carol', 'has anyone seen the release notes for 1.0? they look long'], [1, 18, 41, 'dave', '\u0001ACTION goes to read them\u0001'], [1, 18, 44, 'eve_the_longer_named', 'they are long, but the YouTube cards alone are worth it https://www.youtube.com/watch?v=dQw4w9WgXcQ'], [0, 10, 2, 'frank', 'today is the day'], [0, 10, 3, 'alice', `${NICK}: can you check the channel list on your end? something looks off with the badges`], [0, 10, 5, 'bob', 'it is \u0002bold\u0002, it is \u000304red\u0003, it is \u001dslanted\u001d - IRC formatting still renders'], [0, 10, 6, 'carol', 'a longer message to see how wrapping looks in the message list when somebody writes a whole paragraph instead of a quick line, which happens more often than you would think in a channel like this one'], [0, 10, 7, 'carol', 'and a second one right after, so grouping shows'], [0, 10, 9, 'dave', 'lunch?'] ], '#random': [ [0, 9, 30, 'bob', 'random thought: tabs or spaces'], [0, 9, 31, 'frank', 'spaces, obviously'], [0, 9, 33, 'eve_the_longer_named', 'tabs, and I will not be taking questions'] ], '#dev': [ [0, 8, 0, 'alice', 'build is green'], [0, 8, 1, 'carol', `${NICK}: review when you get a sec?`] ], '#announcements': [[2, 12, 0, 'frank', 'Maintenance window on Saturday, 02:00-04:00 UTC']] } const CHANNELS = Object.keys(HISTORY) function onJoin(channel, _nick, send) { for (const [daysAgo, hh, mm, who, text] of HISTORY[channel] ?? []) { send(`${at(when(daysAgo, hh, mm))}:${who}!u@h PRIVMSG ${channel} :${text}`) } if (channel === '#general') { send(`${at(when(0, 10, 10))}:frank!u@h PART #general :off to lunch`) send(`${at(when(0, 10, 11))}:grace!u@h JOIN #general`) } } // --- Discord-shaped state, for the screens IRC cannot reach ---------------- /** * Runs in the page: a Discord account with two guilds, channels in * categories, a call in progress and a conversation with reactions and a * reply, put straight into the store. */ const DISCORD = `(() => { const shots = window.__mohoShots const s = shots.state() const acc = 'discord:shots' const g1 = acc + '|guild:1', g2 = acc + '|guild:2' const now = Math.floor(Date.now() / 1000) const ch = (id, name, category, extra = {}) => ({ id: acc + '|' + id, accountId: acc, kind: 'channel', name: 'Button Eye Crochet/#' + name, lastActivityTs: now, groupId: g1, category, position: Number(id.replace(/\\D/g, '')) || 0, unread: 0, highlight: false, mentions: 0, ...extra }) const buffers = [ ch('c1', '๐Ÿ“œ-rules', undefined), ch('c2', '๐Ÿ“ข-announcements', undefined, { unread: 2 }), ch('c3', '๐Ÿค -general', 'TEXT CHANNELS'), ch('c4', '๐Ÿ“น-clips', 'TEXT CHANNELS', { unread: 5 }), ch('c5', '๐Ÿ“ท-screenshots', 'TEXT CHANNELS', { unread: 1, highlight: true, mentions: 1 }), ch('c6', '๐Ÿ’ฉ-shitpost', 'TEXT CHANNELS', { unread: 9 }), ch('c7', '๐ŸŽจ-crafts', 'TEXT CHANNELS'), ch('c8', '๐Ÿˆ-cats', 'ANIMALS', { unread: 3 }), ch('c9', '๐Ÿ•-dogs', 'ANIMALS') ] const general = acc + '|c3' const m = (id, from, body, mins, extra = {}) => ({ id, bufferId: general, from, body, ts: now - mins * 60, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, senderId: from, ...extra }) const messages = [ m('m1', 'Gaunt King', 'anyone else going to the craft fair this weekend?', 50, { reactions: [{ emoji: '๐Ÿ‘', count: 3, me: true }, { emoji: '๐Ÿงถ', count: 1, me: false }] }), m('m2', 'Clarence', 'yes! bringing the new amigurumi batch', 48), m('m3', 'Clarence', 'pics later', 48), m('m4', 'Salastil', 'I can man the table for an hour after lunch', 40, { isOwn: true, edited: true }), m('m5', 'Gaunt King', 'perfect - @Salastil you are a lifesaver', 38, { isHighlight: true, replyTo: { id: 'm4', from: 'Salastil', body: 'I can man the table for an hour after lunch' } }), m('m6', 'Wren', 'tutorial for the eye stitch, for anyone who asked', 20, { embeds: [{ title: 'Safety eyes vs embroidered eyes - which to use', description: 'A short guide to choosing and attaching eyes on crochet toys.', color: 0xff0000, url: 'https://example.com/eyes', provider: 'YouTube', author: 'Hooked Studio' }], reactions: [{ emoji: 'โค๏ธ', count: 4, me: false }] }), m('m7', 'Clarence', 'that is exactly what I needed, thank you', 18) ] const members = (who) => who.map(([nick, status]) => ({ nick, userId: nick, status, away: status === 'offline' })) shots.patch({ accounts: [...s.accounts.filter((a) => a.id !== acc), { id: acc, service: 'discord', displayName: 'Salastil', state: 'connected', autojoin: '', currentNick: 'Salastil', status: 'online', useTor: false, ssl: true }], groups: [ ...s.groups.filter((g) => g.accountId !== acc), { id: g1, accountId: acc, service: 'discord', kind: 'guild', name: 'Button Eye Crochet', position: 0 }, { id: g2, accountId: acc, service: 'discord', kind: 'guild', name: 'Moho Dev', position: 1 } ], buffers: [...s.buffers.filter((b) => b.accountId !== acc), ...buffers, { id: acc + '|d1', accountId: acc, kind: 'channel', name: 'Moho Dev/#general', lastActivityTs: now, groupId: g2, unread: 33, highlight: true, mentions: 2 }], messagesByBuffer: { ...s.messagesByBuffer, [general]: messages }, presenceByBuffer: { ...s.presenceByBuffer, [general]: members([['Gaunt King', 'online'], ['Clarence', 'idle'], ['Wren', 'dnd'], ['Salastil', 'online'], ['Old Friend', 'offline'], ['Quiet One', 'offline']]) }, activeGroupId: g1, activeBufferId: general }) // The window asks the daemon about voice as soon as the guild is on screen, // and the daemon knows of no such guild - so this part is put back after // that answer has landed (see VOICE). window.__shotsVoice = { voiceGuildId: '1', voiceChannels: [ { id: 'v1', name: '๐Ÿฆ…๐Ÿชบ-the-eagles-nest', userLimit: 0, occupants: 3, empty: false, members: [ { userId: 'Salastil', nick: 'Salastil', isSelf: true }, { userId: 'Clarence', nick: 'Clarence', isSelf: false, muted: true }, { userId: 'Wren', nick: 'Wren', isSelf: false, streaming: true } ] }, { id: 'v2', name: 'quiet room', userLimit: 4, occupants: 0, empty: true, members: [] } ], voiceSessions: [{ accountId: acc, guildId: '1', channelId: 'v1', channelName: 'the-eagles-nest', isDirect: false }] } return true })()` const VOICE = `window.__mohoShots.patch(window.__shotsVoice), true` const NO_CALL = `window.__mohoShots.patch({ ...window.__shotsVoice, voiceSessions: [] }), true` /** The Discord-shaped state, with its voice part put back once the window has asked. */ const discord = (inCall) => async (page) => { await page.evaluate(DISCORD) await sleep(700) await page.evaluate(inCall ? VOICE : NO_CALL) await sleep(300) } // --- GIFs (#331) --------------------------------------------------------------- /** * Three messages that link to GIFs, with or without what the daemon or Discord * says about them: a Tenor page, a Giphy page and a Klipy page. Without, they * are the addresses they were before; with, the first two are the GIFs - the * Klipy page is one nothing can read, and stays a link either way. * * The pictures are made with ffmpeg and handed to the window as blob and data * URLs, since it plays only https media and nothing is fetched from anywhere. */ const stageGifs = (withEmbeds) => async (page) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-gifs-')) const clip = (name, source) => { const file = path.join(dir, name) execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', source, '-pix_fmt', 'yuv420p', '-y', file]) return fs.readFileSync(file).toString('base64') } const mp4 = clip('cat.mp4', 'gradients=s=300x200:d=3:r=15:speed=0.02:c0=0xe0a43a:c1=0xb0457a:c2=0x3a8fe0:nb_colors=3') const gif = (() => { const file = path.join(dir, 'wave.gif') execFileSync('ffmpeg', ['-v', 'error', '-f', 'lavfi', '-i', 'gradients=s=260x180:d=2:r=12:speed=0.03:c0=0x3ae0a4:c1=0x4a2f9a:nb_colors=2', '-y', file]) return fs.readFileSync(file).toString('base64') })() fs.rmSync(dir, { recursive: true, force: true }) await page.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const bytes = Uint8Array.from(atob('${mp4}'), (c) => c.charCodeAt(0)) const MP4 = URL.createObjectURL(new Blob([bytes], { type: 'video/mp4' })) const GIF = 'data:image/gif;base64,${gif}' const msg = (n, from, body, embeds) => ({ id: 'gif' + n, bufferId: id, from, senderId: from, body, ts: now - (60 - n) * 20, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, ...(embeds ? { embeds } : {}) }) const embeds = ${withEmbeds ? 'true' : 'false'} shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []), msg(1, 'alice', 'this is how it feels on a monday https://tenor.com/view/monday-cat-gif-22954713', embeds ? [{ kind: 'gif', url: 'https://tenor.com/view/monday-cat-gif-22954713', mediaUrl: MP4, mediaKind: 'video', width: 300, height: 200, provider: 'Tenor' }] : undefined), msg(2, 'bob', 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', embeds ? [{ kind: 'gif', url: 'https://giphy.com/gifs/wave-hello-Z7wFi5b2VEAr6', mediaUrl: GIF, mediaKind: 'image', width: 260, height: 180, provider: 'Giphy' }] : undefined), msg(3, 'carol', 'and from klipy: https://klipy.com/gifs/friends-joey-53') ] } }) return true })()`) await sleep(900) } /** The GIF picker, open, with a handful of invented GIFs to look through. */ const openGifPicker = async (page) => { await page.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const art = (a, b, label, w, h) => 'data:image/svg+xml;utf8,' + encodeURIComponent('' + label + '') const gif = (id, title, a, b, w, h) => ({ id, title, url: 'https://tenor.com/view/' + id, src: '', gifSrc: '', preview: art(a, b, title, w, h), width: w, height: h }) window.__gifFixture = { categories: [{ name: 'reaction', src: '' }, { name: 'happy', src: '' }, { name: 'dancing', src: '' }, { name: 'cats', src: '' }, { name: 'applause', src: '' }, { name: 'facepalm', src: '' }], gifs: [ gif('1', 'cat dance', '#e0a43a', '#b0457a', 480, 300), gif('2', 'thumbs up', '#3a8fe0', '#4a2f9a', 300, 300), gif('3', 'happy dog', '#3ae0a4', '#1f6f5a', 480, 270), gif('4', 'mind blown', '#e05a3a', '#7a2f2f', 300, 360), gif('5', 'clapping', '#8a5ae0', '#3a3fa0', 480, 270), gif('6', 'wow', '#e0d03a', '#a07a1f', 300, 300) ] } shots.patch({ accounts: [...s.accounts, { id: 'discord:gifs', service: 'discord', displayName: 'x', state: 'connected', autojoin: '', currentNick: 'x', status: 'online', useTor: false, ssl: true }] }) return true })()`) await sleep(500) await page.evaluate(click('button[title="GIFs"]')) await until(page, 'document.querySelector(".gif-cell")', 'the GIFs') await sleep(500) } // --- scenes ------------------------------------------------------------------ /** Messages with several pictures, one of them a spoiler (#304): two, three and four, then a lone spoiler. */ const stageGrids = async (page) => { await page.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const pic = (a, b, label) => 'data:image/svg+xml;utf8,' + encodeURIComponent( '' + label + '') const att = (name, a, b, label) => ({ kind: 'image', filename: name, width: 1200, height: 800, url: pic(a, b, label) }) const colours = [['#e0a43a', '#b0457a', 'owl'], ['#3a8fe0', '#4a2f9a', 'squares'], ['#3ae0a4', '#1f6f5a', 'blanket'], ['#e05a3a', '#7a2f2f', 'scarf']] const msg = (i, from, body, n, spoilerAt) => ({ id: 'grid' + i, bufferId: id, from, senderId: from, body, ts: now - (8 - i) * 30, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, attachments: colours.slice(0, n).map(([a, b, l], k) => att((k === spoilerAt ? 'SPOILER_' : '') + l + '.svg', a, b, l)) }) shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []), msg(1, 'Wren', 'two from the fair', 2, -1), msg(2, 'Clarence', 'three more, the last one is a spoiler', 3, 2), msg(3, 'Wren', 'and four', 4, -1), msg(4, 'Clarence', 'just one, and a spoiler', 1, 0)] } }) return true })()`) await sleep(900) } /** Three pictures posted in a row, drawn as SVG so nothing is fetched. */ const stageLightbox = async (page) => { await page.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const pic = (name, a, b, label) => 'data:image/svg+xml;utf8,' + encodeURIComponent( '' + label + '') const msg = (i, label, a, b) => ({ id: 'pic' + i, bufferId: id, from: 'Wren', senderId: 'Wren', body: '', ts: now - (3 - i) * 20, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, attachments: [{ kind: 'image', filename: label.toLowerCase() + '.svg', width: 1600, height: 1000, url: pic(i, a, b, label) }] }) shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...(s.messagesByBuffer[id] || []), msg(1, 'Amigurumi', '#e0a43a', '#b0457a'), msg(2, 'Granny squares', '#3a8fe0', '#4a2f9a'), msg(3, 'Blanket', '#3ae0a4', '#1f6f5a')] } }) return true })()`) await sleep(700) } const mouse = async (page, type, x, y, button = 'none') => page.call('Input.dispatchMouseEvent', { type, x, y, button, clickCount: button === 'none' ? 0 : 1 }) /** The centre of the first element matching, in window coordinates. */ const centre = (page, selector, text = '') => page.evaluate(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find((e) => e.innerText.includes(${JSON.stringify(text)})) if (!el) return null el.scrollIntoView({ block: 'center' }) const r = el.getBoundingClientRect() return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + Math.min(r.height / 2, 14)) } })()`) /** A key with modifiers held (CDP's bitmask: 1 alt, 2 ctrl, 4 meta, 8 shift). */ const pressKey = async (page, key, code, modifiers = 0) => { for (const type of ['keyDown', 'keyUp']) { await page.call('Input.dispatchKeyEvent', { type, key, code, modifiers, windowsVirtualKeyCode: key.toUpperCase().charCodeAt(0) }) } } /** A real left click on the centre of an element, as a person's pointer would make. */ const realClick = async (page, selector) => { const at = await page.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`) if (!at) throw new Error(`nothing matches ${selector}`) await mouse(page, 'mousePressed', at.x, at.y, 'left') await mouse(page, 'mouseReleased', at.x, at.y, 'left') } const escape = async (page) => { for (const type of ['keyDown', 'keyUp']) { await page.call('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }) } await sleep(150) } const openChannel = async (page, name) => { await page.evaluate(click('nav[aria-label=Servers] [aria-label*="localhost"]')) await sleep(200) await page.evaluate(click('.buffer-row', name)) await sleep(500) } const setMode = async (page, mode) => { await page.evaluate(`window.moho.prefs.set('display.messageMode', ${JSON.stringify(mode)})`) // The window reads its preferences once and hears only about other // windows' changes, so a reload is what makes this one see its own. await page.call('Page.reload') await until(page, 'window.__mohoShots && document.querySelector("nav[aria-label=Servers] [aria-label]")', 'the window after a reload') await page.call('Emulation.setDeviceMetricsOverride', { ...SIZE, deviceScaleFactor: 1, mobile: false }) await sleep(600) } /** * Each scene puts the window into a state and is photographed there. * `video` scenes are recorded instead, while `act` runs. */ const SCENES = [ { name: 'gif-links-before', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(false)(p) } }, { name: 'gif-links-after', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await stageGifs(true)(p) } }, { name: 'gif-picker', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general'); await openGifPicker(p) } }, { // What somebody did on a Kick stream: KICKs sent, subscriptions gifted. Put // on a channel's messages directly - a stream is not here to do it. name: 'kick-events', setup: async (p, mode = 'classic') => { await setMode(p, mode) await openChannel(p, '#general') await p.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const row = (n, kind, body, embed, from = 'bob') => ({ id: 'k' + n, bufferId: id, from, senderId: from, body, ts: now - 60 + n, isAction: false, isHighlight: false, kind, isOwn: false, embeds: embed ? [embed] : [] }) const chat = (n, from, body) => row(n, 'chat', body, null, from) shots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [ chat(1, 'bob', 'that was a clean run'), row(2, 'kicks', 'Currington sent 1 Kick', { kind: 'kicks', author: 'Currington', authorColor: '#75FD46', title: '1', provider: 'Hell Yeah', color: 0x53fc18 }), chat(3, 'carol', 'lets gooo'), row(4, 'kicks', 'Maverick sent 500 Kicks: take my money', { kind: 'kicks', author: 'Maverick', authorColor: '#E26EFF', title: '500', provider: 'Full Send', description: 'take my money', color: 0xb45cff }), row(5, 'sub', 'ItsDez gifted 5 subscriptions', { kind: 'gift-sub', author: 'ItsDez', title: '5', description: 'Interstelar\\nRon_OSRS\\nAuzzzii\\nMeowtimeJay\\nstradivarius31' }), row(6, 'sub', 'LurkinLeepa gifted a subscription', { kind: 'gift-sub', author: 'LurkinLeepa', title: '1', description: 'onkhell' }), row(10, 'sub', 'bigkhernandez subscribed - 21 months', { kind: 'subscribe', author: 'bigkhernandez', title: '21' }), row(11, 'moderation', 'troll was timed out by mod', { kind: 'moderation', author: 'troll', title: 'was timed out', description: 'mod' }), row(12, 'stream', 'went live', { kind: 'stream', author: 'streamer', title: 'went live', icon: 'live' }), row(7, 'raid', 'kreationX raided with 25 viewers', { kind: 'raid', author: 'kreationX', title: '25' }), row(8, 'reward', 'onkhell redeemed 5 TONGUE TWISTERS', { kind: 'redeem', author: 'onkhell', title: '5 TONGUE TWISTERS', color: 0xff55b3 }), chat(9, 'bob', 'thanks everybody') ] } }) })()`) await sleep(500) } }, { name: 'kick-events-comfy', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'comfy') } }, { name: 'kick-events-bubbles', setup: async (p) => { await SCENES.find((x) => x.name === 'kick-events').setup(p, 'bubbles') } }, { name: 'irc-channel-comfy', setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general') } }, { // History from several days, scrolled to its start: where date // separators belong, and where the "jump to present" control shows. name: 'irc-history-top', setup: async (p) => { await setMode(p, 'comfy') await openChannel(p, '#general') await p.evaluate(`document.querySelector('.messagelist-scroll').scrollTop = 0`) await sleep(400) } }, { name: 'irc-channel-classic', setup: async (p) => { await setMode(p, 'classic'); await openChannel(p, '#general') } }, { name: 'irc-channel-bubbles', setup: async (p) => { await setMode(p, 'bubbles'); await openChannel(p, '#general') } }, { name: 'irc-hover-toolbar', setup: async (p) => { await setMode(p, 'comfy') await openChannel(p, '#general') const c = await centre(p, '.message-row', 'whole paragraph') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(300) } }, { name: 'irc-message-menu', setup: async (p) => { await openChannel(p, '#general') const c = await centre(p, '.message-row', 'lunch?') await mouse(p, 'mousePressed', c.x, c.y, 'right') await mouse(p, 'mouseReleased', c.x, c.y, 'right') await sleep(300) } }, { name: 'irc-composer-reply-typing', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`(() => { const s = window.__mohoShots.state() const id = s.activeBufferId const msg = (s.messagesByBuffer[id] || []).find((m) => m.body.includes('badges')) if (msg) window.__mohoShots.store.startReply(msg.id, msg.from, msg.body) window.__mohoShots.patch({ typingByBuffer: { ...s.typingByBuffer, [id]: { nicks: ['carol', 'dave'], until: Date.now() + 60000 } } }) return true })()`) await sleep(300) } }, { name: 'emoji-picker', setup: async (p) => { await openChannel(p, '#general'); await p.evaluate(click('button[title="Emoji"]')); await sleep(400) } }, { // Files in the tray: a card each with name and size, one marked a spoiler (#308). name: 'discord-tray', setup: async (p) => { await discord(false)(p) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-tray-')) const make = (name, bytes) => { const f = path.join(dir, name); fs.writeFileSync(f, Buffer.alloc(bytes, 1)); return f } const files = [make('finished-amigurumi-owl.png', 482_000), make('pattern-notes.pdf', 1_900_000), make('workshop-clip.mp4', 12_400_000), make('granny-square-closeup.jpg', 233_000)] await p.evaluate(`(async () => { const art = (a, b, label) => new Promise((resolve) => { const c = document.createElement('canvas'); c.width = 320; c.height = 200 const g = c.getContext('2d'); const grad = g.createLinearGradient(0, 0, 320, 200); grad.addColorStop(0, a); grad.addColorStop(1, b) g.fillStyle = grad; g.fillRect(0, 0, 320, 200); g.fillStyle = '#fff'; g.font = '28px sans-serif'; g.textAlign = 'center'; g.fillText(label, 160, 108) c.toBlob((blob) => resolve(blob)) }) const owl = await art('#e0a43a', '#b0457a', 'owl') const squares = await art('#3a8fe0', '#4a2f9a', 'squares') const mk = (blob, name) => new File([blob], name, { type: 'image/png' }) window.dispatchEvent(new CustomEvent('moho:stage-files', { detail: [ { path: ${JSON.stringify(files[0])}, file: mk(owl, 'owl.png') }, { path: ${JSON.stringify(files[1])} }, { path: ${JSON.stringify(files[2])} }, { path: ${JSON.stringify(files[3])}, file: mk(squares, 'squares.png') } ] })) })()`) await sleep(700) // The last one hidden, and the pointer over the first. await p.evaluate(`document.querySelectorAll('.staged-card')[3].querySelector('.staged-action[title="Mark as spoiler"]').click()`) const c = await centre(p, '.staged-card', 'owl') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(400) }, clip: { x: 277, y: 560, width: 823, height: 240 } }, { // A file dropped on the window (#308): the old build asked first and sent it on its own; // the new one puts it in the tray. name: 'discord-drop', setup: async (p) => { await discord(false)(p) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-drop-')) const file = path.join(dir, 'finished-amigurumi-owl.png') execFileSync('convert', ['-size', '480x300', 'gradient:#e0a43a-#b0457a', file]) const drag = (type) => p.call('Input.dispatchDragEvent', { type, x: 600, y: 300, data: { items: [], files: [file], dragOperationsMask: 1 } }) await drag('dragEnter') await drag('dragOver') await sleep(300) await drag('drop') await sleep(800) } }, { // Only for the "before" half of #308: the old tray, drawn from the markup it had, since // nothing in the old build could put a file there without a dialog or the clipboard. name: 'discord-tray-before', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const mk = (a, b) => { const c = document.createElement('canvas'); c.width = 144; c.height = 144; const g = c.getContext('2d'); const grad = g.createLinearGradient(0, 0, 144, 144); grad.addColorStop(0, a); grad.addColorStop(1, b); g.fillStyle = grad; g.fillRect(0, 0, 144, 144); return c.toDataURL() } const tile = (inner) => '
' + inner + '
' const html = '
' + tile('') + tile('
descriptionpattern-notes.pdf
') + tile('
descriptionworkshop-clip.mp4
') + tile('') + '
' document.querySelector('.composer-row').insertAdjacentHTML('beforebegin', html) })()`) await sleep(400) }, clip: { x: 277, y: 560, width: 823, height: 240 } }, { // The "+" with more than a file in it (#306): Matrix's place and poll live here now. name: 'matrix-plus-menu', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`(() => { const s = window.__mohoShots.state() window.__mohoShots.patch({ accounts: s.accounts.map((a) => ({ ...a, service: 'matrix' })) }) return true })()`) await sleep(400) await p.evaluate(`document.querySelector('.composer-input').focus()`) await p.call('Input.insertText', { text: 'with a place and a poll under the plus' }) await p.evaluate(click('.composer-plus')) await sleep(500) }, clip: { x: 277, y: 520, width: 823, height: 280 } }, { // Walked with the arrow keys, with the skin tones open (#313). name: 'emoji-picker-keys', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(click('button[title="Emoji"]')) await sleep(400) for (const [key, code, vk] of [['ArrowDown', 'ArrowDown', 40], ['ArrowRight', 'ArrowRight', 39], ['ArrowRight', 'ArrowRight', 39], ['ArrowDown', 'ArrowDown', 40]]) { for (const type of ['keyDown', 'keyUp']) await p.call('Input.dispatchKeyEvent', { type, key, code, windowsVirtualKeyCode: vk }) await sleep(120) } await p.evaluate(`document.querySelector('.emoji-tone-button')?.click()`) await sleep(400) } }, { // The mentions page (#314): filters, days, two lines, put away. name: 'mentions-page', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const now = Math.floor(Date.now() / 1000) const m = (id, bufferId, from, body, ts) => ({ id, bufferId, from, senderId: from, body, ts, isAction: false, isHighlight: true, kind: 'chat', isOwn: false }) window.__mohoShots.patch({ mentions: [ m('x1', 'discord:shots|c3', 'Gaunt King', 'perfect - @Salastil you are a lifesaver, and could you also bring the felt kits on Saturday when you come by the fair? we are short on the small ones', now - 600), m('x2', 'discord:shots|c5', 'Clarence', '@Salastil the new screenshots are up, can you check the colours before I post them?', now - 3 * 3600), m('x3', 'discord:shots|c3', 'Wren', 'ping @Salastil', now - 30 * 3600), m('x4', 'discord:shots|c8', 'Fern', '@Salastil did the cats channel get the new pinned rules?', now - 54 * 3600) ], lastReadTs: { 'discord:shots|c3': now - 2 * 3600, 'discord:shots|c5': now - 4 * 3600, 'discord:shots|c8': now } }) window.__mohoShots.store.selectGroup && window.__mohoShots.store.selectGroup('~mentions') return true })()`) await sleep(500) } }, { // Room settings as a dialog with pages (#324); the room's answers are not there to read, so it shows its frame. name: 'room-settings-dialog', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.map((b) => b.id === s.activeBufferId ? { ...b, accountId: 'matrix:' + b.accountId } : b), accounts: s.accounts.map((a) => ({ ...a, id: 'matrix:' + a.id, service: 'matrix' })) }); return true })()`) await sleep(500) await p.evaluate(click('button[title="What this room keeps"]')) await sleep(700) } }, { name: 'mentions-inbox', setup: async (p) => { await p.evaluate(click('button[title="Mentions"]')); await sleep(400) } }, { name: 'modal-export', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(click('.header-nameplate')) await sleep(300) await p.evaluate(click('.context-menu-item', 'Export')) await sleep(600) } }, { // The date fields appear only with "A range" chosen; they are what had no border colour (#268). name: 'modal-export-range', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(click('.header-nameplate')) await sleep(300) await p.evaluate(click('.context-menu-item', 'Export')) await sleep(500) await p.evaluate(`[...document.querySelectorAll('.reason-prompt label')].find((l) => l.innerText.includes('A range'))?.click()`) await sleep(400) } }, { // The pointer over a channel and over a member: how visible a hover is. name: 'irc-hover-rows', setup: async (p) => { await openChannel(p, '#general') const c = await centre(p, '.buffer-row', 'dev') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(300) }, clip: { x: 0, y: 44, width: 420, height: 300 } }, { name: 'irc-hover-member', setup: async (p) => { await openChannel(p, '#general') const c = await centre(p, '.nick-row', 'carol') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(300) }, clip: { x: 860, y: 44, width: 420, height: 300 } }, { name: 'toasts', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`(() => { const t = window.__mohoShots.store; t.toast('info', 'Copied to the clipboard'); t.toast('success', 'Link copied'); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); t.toast('error', "Couldn't send: the connection dropped"); return true })()`) await sleep(300) } }, { name: 'settings-presence', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(400) await p.evaluate(`(() => { const el = [...document.querySelectorAll('h1, h2, h3, h4, .settings-section-title')].find((e) => e.textContent.trim() === 'Presence') el?.scrollIntoView({ block: 'start' }) return !!el })()`) await sleep(400) } }, { name: 'settings-general', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`); await sleep(400) } }, { name: 'settings-tor', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(300) await p.evaluate(click('.settings-rail-item', 'Tor')) await sleep(400) } }, { // Searching across every page (#319). name: 'settings-search', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(400) await p.evaluate(`document.querySelector('.settings-search-input').focus()`) await p.call('Input.insertText', { text: 'timestamp' }) await sleep(500) } }, { name: 'settings-appearance', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(300) await p.evaluate(click('.settings-rail-item', 'Appearance')) await sleep(500) } }, { // Compact spacing, 12-hour clock, a larger zoom (#320). name: 'appearance-compact', setup: async (p) => { await p.evaluate(`window.moho.prefs.set('appearance.density', 'compact'), window.moho.prefs.set('display.hourFormat', '12'), window.moho.prefs.set('appearance.zoom', 1.15)`) // Read at start, as a window opened later would. await p.evaluate(`location.reload()`) await sleep(2500) await openChannel(p, '#general') await sleep(900) } }, { name: 'settings-notifications', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(300) await p.evaluate(click('.settings-rail-item', 'Notifications')) await sleep(500) } }, { name: 'settings-keybinds', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(300) await p.evaluate(click('.settings-rail-item', 'Keybinds')) await sleep(500) } }, { // Join: which account it is for, first, and a button that says Join (#323). name: 'join-picker', setup: async (p) => { await discord(false)(p) await p.evaluate(`window.__mohoShots.store.setActivePanel('join', 'discord:shots')`) await sleep(600) } }, { // An account card open: Remove lives in a danger zone at its foot (#323). name: 'accounts-expanded', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`window.__mohoShots.store.setActivePanel('accounts')`) await sleep(500) await p.evaluate(`document.querySelector('.account-card .icon-button[title="Settings"]')?.click()`) await sleep(500) await p.evaluate(`(document.querySelector('.settings .panel') || document.querySelector('.panel')).scrollTop = 1e6`) await sleep(300) } }, { name: 'settings-accounts', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('accounts')`); await sleep(400) } }, { name: 'discord-guild', setup: discord(false) }, { name: 'discord-voice-panel', setup: async (p) => { await discord(true)(p) // Somebody speaking, and a healthy connection. await p.evaluate(`window.__shotsLevels = { micPeak: 0.05, rttMs: 42 }, true`) await sleep(500) }, clip: { x: 0, y: 480, width: 300, height: 320 } }, { // Right-clicking a server's own category heading, which used to open nothing (#286). name: 'discord-category-menu', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.category-head', 'ANIMALS') if (c) { await mouse(p, 'mousePressed', c.x, c.y, 'right') await mouse(p, 'mouseReleased', c.x, c.y, 'right') } await sleep(300) }, clip: { x: 0, y: 44, width: 420, height: 420 } }, { name: 'discord-server-menu', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.rail-tile', '') const tile = await p.evaluate(`(() => { const el = document.querySelector('[title="Moho Dev"]'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`) const at = tile ?? c if (at) { await mouse(p, 'mousePressed', at.x, at.y, 'right') await mouse(p, 'mouseReleased', at.x, at.y, 'right') } await sleep(300) }, clip: { x: 0, y: 44, width: 420, height: 300 } }, { // Ctrl+K with nothing typed: what is waiting first (#287). name: 'discord-switcher', setup: async (p) => { await discord(false)(p) await pressKey(p, 'k', 'KeyK', 2) await sleep(400) } }, { name: 'discord-switcher-query', setup: async (p) => { await discord(false)(p) await pressKey(p, 'k', 'KeyK', 2) await sleep(300) await p.call('Input.insertText', { text: 'gen' }) await sleep(400) } }, { name: 'discord-voice-panel-poor', setup: async (p) => { await discord(true)(p) await p.evaluate(`window.__shotsLevels = { micPeak: 0.13, rttMs: 210 }, true`) await sleep(500) }, clip: { x: 0, y: 480, width: 300, height: 320 } }, { // The pointer over a reply (#296) and over a name (#297): both are links now. name: 'discord-reply-hover', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.reply-preview', 'man the table') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(300) }, clip: { x: 277, y: 260, width: 640, height: 90 } }, { // The reply bar (#307): its own surface, the author's face, the @ switch. name: 'discord-reply-bar', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() const msg = (s.messagesByBuffer[s.activeBufferId] || []).find((m) => m.body.includes('table')) if (msg) window.__mohoShots.store.startReply(msg.id, msg.from, msg.body) return true })()`) await sleep(400) }, clip: { x: 277, y: 640, width: 1000, height: 160 } }, { name: 'discord-name-hover', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.message-from', 'Gaunt King') if (c) await mouse(p, 'mouseMoved', c.x - 20, c.y) await sleep(300) }, clip: { x: 277, y: 80, width: 640, height: 90 } }, { // Right-click on highlighted text in a Discord message (#299). name: 'discord-message-menu', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const row = [...document.querySelectorAll('.message-row')].find((r) => r.innerText.includes('craft fair')) const body = row.querySelector('.message-body') const range = document.createRange(); range.selectNodeContents(body) const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range) return true })()`) const c = await centre(p, '.message-row', 'craft fair') await mouse(p, 'mousePressed', c.x + 120, c.y, 'right') await mouse(p, 'mouseReleased', c.x + 120, c.y, 'right') await sleep(300) }, clip: { x: 277, y: 80, width: 640, height: 420 } }, { // The pointer over your own message: Edit and Copy join the toolbar (#298). name: 'discord-hover-toolbar', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.message-row', 'man the table for an hour after lunch') if (c) await mouse(p, 'mouseMoved', c.x + 100, c.y + 8) await sleep(400) }, clip: { x: 277, y: 200, width: 823, height: 120 } }, { // A folded category with unread channels in it, and muted channels (#275, #276). name: 'discord-folded-muted', setup: async (p) => { await discord(false)(p) // Muted through the window's own menu, as a person would: one quiet // (shitpost), one with a mention in it (screenshots). for (const name of ['shitpost', 'screenshots']) { const c = await centre(p, '.buffer-row', name) await mouse(p, 'mousePressed', c.x, c.y, 'right') await mouse(p, 'mouseReleased', c.x, c.y, 'right') await sleep(250) await p.evaluate(click('.context-menu-item', 'Mute')) await sleep(400) // A preference change makes the window ask the daemon again, which // knows of no such server; the staged state goes back in. await p.evaluate(DISCORD) await sleep(700) } await p.evaluate(click('.category-head', 'ANIMALS')) await sleep(300) await p.evaluate(click('.category-head', 'TEXT CHANNELS')) await sleep(400) await p.evaluate(DISCORD) await sleep(700) }, clip: { x: 0, y: 44, width: 280, height: 360 } }, // One account in each state the dots can show: the plaque's dot and the server header's (#277). ...[ ['dnd', 'connected', 'dnd'], ['idle', 'connected', 'idle'], ['connecting', 'connecting', 'online'], ['offline', 'disconnected', 'online'] ].flatMap(([name, state, status]) => [ { name: `presence-${name}-footer`, setup: async (p) => { await discord(false)(p) await p.evaluate(`window.__mohoShots.patch({ accounts: window.__mohoShots.state().accounts.map((a) => a.id === 'discord:shots' ? { ...a, state: '${state}', status: '${status}' } : a) })`) await sleep(400) }, clip: { x: 0, y: 740, width: 280, height: 60 } }, { name: `presence-${name}-header`, setup: async (p) => { await discord(false)(p) await p.evaluate(`window.__mohoShots.patch({ accounts: window.__mohoShots.state().accounts.map((a) => a.id === 'discord:shots' ? { ...a, state: '${state}', status: '${status}' } : a) })`) await sleep(400) }, clip: { x: 56, y: 48, width: 224, height: 36 } } ]), { // The rail: mention counts on server tiles and the "+" tile (#285). name: 'rail-tiles', setup: async (p) => { await discord(false)(p) }, clip: { x: 0, y: 44, width: 280, height: 360 } }, { name: 'rail-tooltip', setup: async (p) => { await discord(false)(p) const tile = await p.evaluate(`(() => { const el = document.querySelector('[aria-label="Moho Dev"]'); const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`) await mouse(p, 'mouseMoved', tile.x, tile.y) await sleep(400) }, clip: { x: 0, y: 44, width: 280, height: 160 } }, { // Both servers filed in a folder, closed: it should still say something is waiting. name: 'rail-folder-closed', setup: async (p) => { await discord(false)(p) await p.evaluate(`window.moho.prefs.set('railFolders', [{ id: 'f1', name: 'Mine', colour: '', members: ['discord:shots|guild:1', 'discord:shots|guild:2'] }])`) await sleep(300) await p.call('Page.reload') await until(p, 'window.__mohoShots && document.querySelector(".buffer-row")', 'the window after a reload') await p.call('Emulation.setDeviceMetricsOverride', { ...SIZE, deviceScaleFactor: 1, mobile: false }) await sleep(500) await p.evaluate(DISCORD) await sleep(900) }, clip: { x: 0, y: 44, width: 280, height: 360 } }, { // The pointer over the close button (#291): red, and the maximise glyph. name: 'titlebar-close-hover', setup: async (p) => { await openChannel(p, '#general') const c = await centre(p, '.titlebar-controls button', '') const close = await p.evaluate(`(() => { const b = [...document.querySelectorAll('.titlebar-controls button')].at(-1); const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } })()`) await mouse(p, 'mouseMoved', close.x, close.y) await sleep(300) }, clip: { x: 1020, y: 0, width: 260, height: 44 } }, { // The member list with presence (#288). name: 'discord-members', setup: async (p) => { await discord(false)(p) }, clip: { x: 1100, y: 44, width: 180, height: 300 } }, { // The voice channels: faces, muted and deafened marks, who is speaking, in the call (#293). name: 'discord-voice-list', setup: async (p) => { await discord(true)(p) await p.evaluate(`window.__mohoShots.patch({ voiceChannels: window.__mohoShots.state().voiceChannels.map((c) => c.id === 'v1' ? { ...c, members: [{ userId: 'Salastil', nick: 'Salastil', isSelf: true }, { userId: 'Clarence', nick: 'Clarence', isSelf: false, muted: true }, { userId: 'Wren', nick: 'Wren', isSelf: false, streaming: true }, { userId: 'Fern', nick: 'Fern', isSelf: false, deafened: true }] } : c) }), window.__shotsSpeakers = ['Wren'], true`) await sleep(600) }, clip: { x: 0, y: 380, width: 280, height: 260 } }, { // Restarting Tor asks first (#316). name: 'settings-tor-confirm', setup: async (p) => { await p.evaluate(`window.__mohoShots.store.setActivePanel('settings')`) await sleep(300) await p.evaluate(click('.settings-rail-item', 'Tor')) await sleep(300) await p.evaluate(click('button', 'Restart Tor from scratch')) await sleep(300) } }, { // A thread beside the room: grouped replies, the plural, the display settings (#269). name: 'discord-thread', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() const bufferId = 'discord:shots|c3' const now = Math.floor(Date.now() / 1000) const m = (id, from, body, mins, replyTo) => ({ id, bufferId, from, body, ts: now - mins * 60, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, senderId: from, ...(replyTo ? { replyTo } : {}) }) const thread = { id: 'm1', from: 'Gaunt King', body: '', thread: true } window.__mohoShots.patch({ openThread: { bufferId, rootId: 'm1', loading: false, messages: [ m('m1', 'Gaunt King', 'anyone else going to the craft fair this weekend?', 50), m('t1', 'Clarence', 'yes! bringing the new amigurumi batch', 48, thread), m('t2', 'Clarence', 'and the felt kits', 47, thread) ] } }) return true })()`) await sleep(600) }, clip: { x: 760, y: 44, width: 520, height: 400 } }, { // The thread's reply box is the room's own (#310): emoji, names, files. name: 'discord-thread-composer', setup: async (p) => { await SCENES.find((x) => x.name === 'discord-thread').setup(p) await p.evaluate(`document.querySelector('.thread-pane .composer-input')?.focus()`) await p.call('Input.insertText', { text: 'count me in :fi' }) await sleep(500) }, clip: { x: 760, y: 44, width: 520, height: 756 } }, { // The status menu (a real check mark) and the cog's menu beside its button (#317). name: 'status-menu', setup: async (p) => { await openChannel(p, '#general') await realClick(p, '.user-identity-button') await sleep(400) }, clip: { x: 0, y: 500, width: 420, height: 300 } }, { name: 'cog-menu', setup: async (p) => { await openChannel(p, '#general') await realClick(p, '.rail-cog') await sleep(400) }, clip: { x: 0, y: 600, width: 420, height: 200 } }, { // The lists that open over the box while a name or an emoji is typed (#309). name: 'discord-autocomplete-mention', setup: async (p) => { await discord(false)(p) const face = 'data:image/svg+xml;utf8,' + encodeURIComponent('') await p.evaluate(`(() => { const s = window.__mohoShots.state() const id = s.activeBufferId const m = (nick, extra = {}) => ({ nick, userId: nick, status: 'online', ...extra }) window.__mohoShots.patch({ presenceByBuffer: { ...s.presenceByBuffer, [id]: [m('Clarence', { avatarUrl: ${JSON.stringify(face)} }), m('Cora'), m('Gaunt King'), m('Wren')] } }) return true })()`) await p.evaluate(`document.querySelector('.composer-input').focus()`) await p.call('Input.insertText', { text: 'thanks @c' }) await sleep(500) }, clip: { x: 277, y: 420, width: 823, height: 380 } }, { name: 'discord-autocomplete-emoji', setup: async (p) => { await discord(false)(p) await p.evaluate(`document.querySelector('.composer-input').focus()`) await p.call('Input.insertText', { text: 'that was :fi' }) await sleep(500) }, clip: { x: 277, y: 420, width: 823, height: 380 } }, { // Pins for Discord (#311): the banner over the log, and the count in the header. name: 'discord-pinned-banner', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const list = s.messagesByBuffer[id] || [] const pick = ['craft fair', 'amigurumi', 'I can man'].map((t) => list.find((m) => m.body.includes(t))).filter(Boolean) shots.patch({ pinnedRows: { ...s.pinnedRows, [id]: pick }, pinnedMessages: { ...s.pinnedMessages, [id]: pick.map((m) => m.id) } }) return true })()`) await sleep(600) }, clip: { x: 277, y: 44, width: 823, height: 260 } }, { // The sentence over a reaction (#303). Names come from the service on request; with the staged // account there is none to ask, so this shows what is said before they arrive. name: 'discord-reaction-tip', setup: async (p) => { await discord(false)(p) const c = await centre(p, '.reaction-pill', '3') if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(600) }, clip: { x: 277, y: 60, width: 640, height: 260 } }, { // The footer (#290): muted and deafened are red, and the status is a popout with its own colours, // a custom status and "apply to all". name: 'discord-footer-muted', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ voicePrefs: { ...s.voicePrefs, micMuted: true, deafened: true } }); return true })()`) await sleep(500) }, clip: { x: 0, y: 700, width: 330, height: 100 } }, { name: 'discord-status-popout', setup: async (p) => { await discord(false)(p) await p.evaluate(click('.user-identity-button')) await sleep(600) }, clip: { x: 0, y: 380, width: 340, height: 420 } }, { // On a service with no voice the footer keeps its shape: the buttons are there, greyed. name: 'irc-footer', setup: async (p) => { await openChannel(p, '#general') await sleep(300) }, clip: { x: 0, y: 700, width: 330, height: 100 } }, { // The header row across the three columns, with the channel's topic in it (#289). name: 'discord-header-topic', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() window.__mohoShots.patch({ buffers: s.buffers.map((b) => b.id === s.activeBufferId ? { ...b, topic: 'General chatter for the shop. Fair dates and pattern swaps are pinned. Be kind. https://example.org/rules' } : b) }) return true })()`) await sleep(500) }, clip: { x: 0, y: 44, width: 1280, height: 70 } }, { name: 'discord-header-topic-open', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() window.__mohoShots.patch({ buffers: s.buffers.map((b) => b.id === s.activeBufferId ? { ...b, topic: 'General chatter for the shop.\\nFair dates and pattern swaps are pinned.\\nBe kind. https://example.org/rules' } : b) }) return true })()`) await sleep(400) await p.evaluate(click('.header-topic')) await sleep(500) }, clip: { x: 277, y: 44, width: 823, height: 260 } }, { // The shared dialog frame (#315): the screen picker had no way out but a click outside it. name: 'dialog-screen-picker', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const thumb = (a, b, l) => 'data:image/svg+xml;utf8,' + encodeURIComponent('' + l + '') window.__mohoShots.patch({ screenSources: [ { id: 's1', name: 'Entire screen', thumbnail: thumb('#2b5876', '#4e4376', 'screen') }, { id: 's2', name: 'moho', thumbnail: thumb('#e0a43a', '#b0457a', 'moho') }, { id: 's3', name: 'Pattern notes - Firefox', thumbnail: thumb('#3ae0a4', '#1f6f5a', 'browser') } ] }) return true })()`) await sleep(600) } }, { name: 'dialog-poll', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ accounts: s.accounts.map((a) => ({ ...a, service: 'matrix' })) }); return true })()`) await sleep(400) await p.evaluate(click('.composer-plus')) await sleep(300) await p.evaluate(click('.context-menu-item', 'poll')) await sleep(600) } }, { // Two calls at once, one from each service (#325): stacked, in one style. name: 'incoming-calls', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() window.__mohoShots.patch({ incomingCalls: [{ bufferId: 'discord:shots|c3', accountId: 'discord:shots' }], ringingCall: { accountId: 'discord:shots', bufferId: 'discord:shots|c8', callId: 'x1', from: 'Wren', video: true, offerSdp: '', expires: Date.now() + 60000 } }) return true })()`) await sleep(600) }, clip: { x: 0, y: 560, width: 460, height: 240 } }, { // Where the box would be, in Discord's own notices (#328). name: 'discord-official-dm', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const shots = window.__mohoShots const s = shots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const notice = (i, title, description, danger, cta, daysAgo) => ({ id: 'n' + i, bufferId: id, from: 'Discord', senderId: 'Discord', body: '', ts: now - daysAgo * 86400, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, embeds: [{ kind: 'notice', title, description, color: danger ? 0xda373c : 0x5865f2, icon: danger ? 'warning' : undefined, timestamp: new Date((now - daysAgo * 86400) * 1000).toISOString(), cta }] }) shots.patch({ buffers: s.buffers.map((b) => b.id === id ? { ...b, readOnly: 'This chat is reserved for official Discord notifications.' } : b), messagesByBuffer: { ...s.messagesByBuffer, [id]: [notice(1, "You broke Discord's community guidelines", "We've taken action that affects your account.", true, { label: 'Learn more', url: 'https://support.discord.com/' }, 7), notice(2, 'We removed a violation from your account', 'We reviewed a violation regarding our policy and determined it does not violate our community guidelines. We have removed this violation from your account.', false, { label: 'Learn more', url: 'https://support.discord.com/' }, 7), notice(3, 'We confirmed your content broke our rules', 'At your request, we reviewed your content and confirmed it violates our community guidelines. This violation still affects your account until it expires.', false, { label: 'See details in Discord' }, 0)] } }) return true })()`) await sleep(600) }, clip: { x: 277, y: 160, width: 823, height: 640 } }, { // A channel that can be read and not written in (#328). name: 'discord-read-only-channel', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state(); window.__mohoShots.patch({ buffers: s.buffers.map((b) => b.id === s.activeBufferId ? { ...b, readOnly: 'You do not have permission to send messages in this channel.' } : b) }); return true })()`) await sleep(500) }, clip: { x: 277, y: 600, width: 823, height: 200 } }, { // Many servers (#329-ish): the list scrolls, the + and the cog stay put. name: 'rail-many-servers', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() const extra = Array.from({ length: 24 }, (_, i) => ({ id: 'discord:shots|guild:x' + i, accountId: 'discord:shots', service: 'discord', kind: 'guild', name: 'Server ' + (i + 1), position: 10 + i })) window.__mohoShots.patch({ groups: [...s.groups, ...extra] }) return true })()`) await sleep(600) await p.evaluate(`document.querySelector('.rail-scroll').scrollTop = 1e6`) await sleep(300) }, clip: { x: 0, y: 44, width: 280, height: 756 } }, { // A Discord forum (#329): a list of posts, not a log, and no message box. name: 'discord-forum', setup: async (p) => { await discord(false)(p) await p.evaluate(`(() => { const s = window.__mohoShots.state() const id = s.activeBufferId const now = Math.floor(Date.now() / 1000) const thumb = (a, b, l) => 'data:image/svg+xml;utf8,' + encodeURIComponent('' + l + '') const post = (i, name, author, content, days, count, extra = {}) => ({ id: 'p' + i, name, author, content, createdTs: now - days * 86400, lastTs: now - days * 86400, messageCount: count, archived: false, pinned: false, tags: [], ...extra }) const posts = [ post(0, 'Read this before posting', 'Moderator', 'Please give a title, say what you are working on, and tag it. Duplicate posts are merged.', 90, 12, { pinned: true, tags: [{ name: 'Rules', emoji: '๐Ÿ“Œ' }] }), post(1, 'n64 test suite', 'nemonic', 'https://github.com/thelemmy/nemu64-test', 8, 0, { tags: [{ name: 'Tools' }], thumbnail: thumb('#f4f4f4', '#e8a23a', 'github') }), post(2, 'Recompilator by nemonicicon', 'Sunny', 'https://github.com/nemonicicon/Recompilator', 18, 2, { tags: [{ name: 'Tools' }, { name: 'N64' }], thumbnail: thumb('#f4f4f4', '#d8506a', 'github') }), post(3, 'n64-decomp-workbench by akratch', 'Sunny', 'https://github.com/akratch/n64-decomp-workbench', 22, 3, { reaction: { emoji: 'โค๏ธ', count: 1 }, thumbnail: thumb('#f4f4f4', '#3a78d8', 'github') }), post(4, 'N64 Game Decompilations', 'binsento', 'https://decomp.dev/?platform=n64', 22, 0, { thumbnail: thumb('#16181d', '#2d5aa8', 'decomp.dev') }), post(5, 'n64dllm-v1 by jlebthedude', 'Sunny', 'https://huggingface.co/jlebthedude/n64dllm-v1', 40, 0, { archived: true, thumbnail: thumb('#2a6fd0', '#e08a3a', 'hf') }), post(6, 'n64recomp-companion by DohmBoy64Bit', 'Sunny', 'https://github.com/DohmBoy64Bit/n64recomp-companion', 52, 1, { archived: true }) ] window.__mohoShots.patch({ buffers: s.buffers.map((b) => b.id === id ? { ...b, forum: true } : b), forumPages: { ...s.forumPages, [id]: { at: Date.now() + 3600000, sort: 'active', posts, hasMore: true, tags: [{ id: 't1', name: 'Tools' }, { id: 't2', name: 'N64' }] } } }) return true })()`) await sleep(700) } }, { name: 'discord-media-grid', setup: async (p) => { await discord(false)(p) await stageGrids(p) await p.evaluate(`document.querySelectorAll('.media-grid')[0]?.scrollIntoView({ block: 'start' })`) await sleep(500) } }, { name: 'discord-media-grid-more', setup: async (p) => { await discord(false)(p) await stageGrids(p) await p.evaluate(`document.querySelector('.messagelist-scroll').scrollTop = 1e9`) await sleep(500) } }, { // The viewer with pictures beside it (#312): stepped through, zoomed with the wheel. name: 'discord-lightbox', setup: async (p) => { await discord(false)(p) await stageLightbox(p) const c = await centre(p, '.media-embed-wrap', '') await p.evaluate(`document.querySelectorAll('.media-embed-wrap img')[1].click()`) await sleep(900) } }, { name: 'discord-lightbox-zoomed', setup: async (p) => { await discord(false)(p) await stageLightbox(p) await p.evaluate(`document.querySelectorAll('.media-embed-wrap img')[1].click()`) await sleep(800) for (let i = 0; i < 6; i++) { await p.call('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 760, y: 330, deltaX: 0, deltaY: -120 }) await sleep(60) } await sleep(400) } }, { // Three lines typed with Shift+Enter (#305). name: 'composer-multiline', setup: async (p) => { await openChannel(p, '#general') await p.evaluate(`document.querySelector('.composer-input').focus()`) const enter = async (mods) => { for (const type of ['keyDown', 'keyUp']) { await p.call('Input.dispatchKeyEvent', { type, key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, modifiers: mods, ...(type === 'keyDown' ? { text: '\r' } : {}) }) } await sleep(120) } await p.call('Input.insertText', { text: 'Agenda for tonight:' }) await enter(8) await p.call('Input.insertText', { text: '1. release notes' }) await enter(8) await p.call('Input.insertText', { text: '2. the open issues' }) await sleep(300) }, clip: { x: 277, y: 560, width: 823, height: 240 } }, // Motion, recorded rather than photographed. { name: 'motion-hover-rows', video: 2500, setup: async (p) => { await setMode(p, 'comfy'); await openChannel(p, '#general') }, act: async (p) => { for (const text of ['#random', '#dev', '#announcements', '#general']) { const c = await centre(p, '.buffer-row', text) if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(350) } for (const text of ['morning all', 'today is the day', 'lunch?']) { const c = await centre(p, '.message-row', text) if (c) await mouse(p, 'mouseMoved', c.x, c.y) await sleep(350) } } }, { name: 'motion-menu-and-modal', video: 2500, setup: async (p) => { await openChannel(p, '#general') }, act: async (p) => { await sleep(200) await p.evaluate(click('.header-nameplate')) await sleep(700) await p.evaluate(click('.context-menu-item', 'Export')) await sleep(900) } }, { name: 'motion-fold', video: 2600, setup: async (p) => { await discord(false)(p) }, act: async (p) => { await sleep(300) await p.evaluate(click('.category-head', 'ANIMALS')) await sleep(900) await p.evaluate(click('.category-head', 'ANIMALS')) await sleep(700) } }, { // A message arriving, and a reaction landing (#283). name: 'motion-message-and-reaction', video: 2800, setup: async (p) => { await discord(false)(p) }, act: async (p) => { await sleep(400) await p.evaluate(`(() => { const s = window.__mohoShots.state() const id = 'discord:shots|c3' const now = Math.floor(Date.now() / 1000) const list = s.messagesByBuffer[id] window.__mohoShots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: [...list, { id: 'live1', bufferId: id, from: 'Clarence', body: 'this one just arrived', ts: now, isAction: false, isHighlight: false, kind: 'chat', isOwn: false, senderId: 'Clarence' }] } }) return true })()`) await sleep(1000) await p.evaluate(`(() => { const s = window.__mohoShots.state() const id = 'discord:shots|c3' window.__mohoShots.patch({ messagesByBuffer: { ...s.messagesByBuffer, [id]: s.messagesByBuffer[id].map((m) => m.id === 'm1' ? { ...m, reactions: m.reactions.map((r) => r.emoji === '๐Ÿ‘' ? { ...r, count: r.count + 1 } : r) } : m) } }) return true })()`) await sleep(900) } }, { name: 'motion-toast', video: 2000, setup: async (p) => { await openChannel(p, '#general') }, act: async (p) => { await sleep(300) await p.evaluate(`window.__mohoShots.store.toast('info', 'Copied to the clipboard')`) await sleep(1200) } } ] // --- capture ----------------------------------------------------------------- async function photograph(page, file, clip) { const { data } = await page.call('Page.captureScreenshot', { format: 'png', ...(clip ? { clip: { ...clip, scale: 1 } } : {}) }) fs.writeFileSync(file, Buffer.from(data, 'base64')) } /** Records the page while `act` runs, as an mp4 at the frames' own timing. */ async function record(page, file, ms, act) { const frames = [] const off = page.on('Page.screencastFrame', (f) => { frames.push({ data: f.data, t: f.metadata.timestamp }) void page.call('Page.screencastFrameAck', { sessionId: f.sessionId }) }) await page.call('Page.startScreencast', { format: 'png', everyNthFrame: 1, maxWidth: SIZE.width, maxHeight: SIZE.height }) const started = Date.now() await act(page) await sleep(Math.max(0, ms - (Date.now() - started))) await page.call('Page.stopScreencast') off() if (frames.length === 0) return false const work = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-rec-')) const lines = [] frames.forEach((f, i) => { const name = path.join(work, `f${String(i).padStart(5, '0')}.png`) fs.writeFileSync(name, Buffer.from(f.data, 'base64')) const next = frames[i + 1]?.t ?? f.t + 0.5 lines.push(`file '${name}'`, `duration ${Math.max(0.01, next - f.t).toFixed(3)}`) }) // The concat demuxer ignores the last duration unless the file is repeated. lines.push(`file '${path.join(work, `f${String(frames.length - 1).padStart(5, '0')}.png`)}'`) fs.writeFileSync(path.join(work, 'list.txt'), lines.join('\n')) execFileSync('ffmpeg', [ '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', path.join(work, 'list.txt'), '-vf', 'fps=30,pad=ceil(iw/2)*2:ceil(ih/2)*2', '-pix_fmt', 'yuv420p', '-c:v', 'libx264', file ]) fs.rmSync(work, { recursive: true, force: true }) return true } // --- the comparison page ----------------------------------------------------- function compare(a, b) { const left = path.join(DIR, a) const right = path.join(DIR, b) const names = [...new Set([...fs.readdirSync(left), ...fs.readdirSync(right)])] .filter((n) => /\.(png|mp4)$/.test(n)) .sort() const cell = (dir, label, name) => { const file = path.join(dir, name) if (!fs.existsSync(file)) return `
${label}

not captured

` const src = `file://${file}` const media = name.endsWith('.mp4') ? `` : `${label} ${name}` return `
${label}
${media}
` } const html = `moho UI: ${a} / ${b}

moho UI, ${a} and ${b}

${names.length} captures, ${new Date().toLocaleString()}

${names.map((n) => `

${n.replace(/\.(png|mp4)$/, '')}

${cell(left, a, n)}${cell(right, b, n)}
`).join('\n')}` const out = path.join(DIR, `compare-${a}-${b}.html`) fs.writeFileSync(out, html) console.log(out) } // --- main ---------------------------------------------------------------------- async function main() { const args = process.argv.slice(2) const flag = (name) => { const i = args.indexOf(name) return i >= 0 ? args[i + 1] : undefined } if (args[0] === '--compare') return compare(args[1] ?? 'before', args[2] ?? 'after') const out = path.join(DIR, flag('--out') ?? 'shots') const only = flag('--only') fs.mkdirSync(out, { recursive: true }) const scenes = SCENES.filter((s) => !only || s.name.includes(only)) const irc = await fakeIrc({ people: PEOPLE, caps: ['server-time'], onJoin }) const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } }) const failed = [] try { await until(page, 'window.__mohoShots', 'the test-only state hook (is the app built from this tree?)') await page.call('Emulation.setDeviceMetricsOverride', { ...SIZE, deviceScaleFactor: 1, mobile: false }) await openIrcChannel(page, { port: irc.address().port, nick: NICK, channels: CHANNELS, waitFor: 'lunch?' }) for (const scene of scenes) { try { // Every scene starts from the same place: nothing open, the IRC // network showing, the mouse out of the way. await escape(page) await page.evaluate(`window.__mohoShots.store.setActivePanel(''), window.__mohoShots.patch({ toasts: [], typingByBuffer: {}, replyingTo: null })`) await mouse(page, 'mouseMoved', SIZE.width - 1, SIZE.height - 1) await scene.setup(page) if (scene.video) { const ok = await record(page, path.join(out, `${scene.name}.mp4`), scene.video, scene.act) console.log(` ${ok ? 'rec ' : 'none'} ${scene.name}`) } else { await sleep(250) await photograph(page, path.join(out, `${scene.name}.png`), scene.clip) console.log(` shot ${scene.name}`) } } catch (e) { failed.push(`${scene.name}: ${e.message}`) console.log(` FAIL ${scene.name}: ${e.message}`) } // The Discord-shaped state goes again before the next scene: a reload // brings back only what the daemon really holds. if (scene.name.startsWith('discord')) { await page.call('Page.reload') await until(page, 'window.__mohoShots && document.querySelector(".buffer-row")', 'the window after a reload') await page.call('Emulation.setDeviceMetricsOverride', { ...SIZE, deviceScaleFactor: 1, mobile: false }) } } } finally { await stop() irc.close() } console.log(`\n${scenes.length - failed.length} of ${scenes.length} captured in ${out}`) if (failed.length) process.exit(1) } main().catch((e) => { console.error(e) process.exit(1) })