Files
moho/scripts/ui-shots.mjs
T

1535 lines
72 KiB
JavaScript

#!/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/<out>/, 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('<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="' + w + '" height="' + h + '" fill="url(#g)"/><text x="' + (w / 2) + '" y="' + (h / 2 + 14) + '" font-size="44" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
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(
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="1200" height="800" fill="url(#g)"/><circle cx="600" cy="400" r="220" fill="none" stroke="#fff" stroke-width="12" stroke-dasharray="36 22"/><text x="600" y="430" font-size="96" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
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(
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="1600" height="1000" fill="url(#g)"/><circle cx="800" cy="500" r="260" fill="none" stroke="#fff" stroke-width="14" stroke-dasharray="40 24"/><text x="800" y="540" font-size="120" font-family="sans-serif" fill="#fff" text-anchor="middle">' + label + '</text></svg>')
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) }
},
{ 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) => '<div class="staged-thumb">' + inner + '<button type="button" class="staged-remove" title="Remove"><span class="icon" style="font-size:13px;width:13px;height:13px;color:var(--error)">close</span></button></div>'
const html = '<div class="composer-attachments">' +
tile('<img src="' + mk('#e0a43a', '#b0457a') + '">') +
tile('<div class="staged-file"><span class="icon">description</span><span class="small ellipsis">pattern-notes.pdf</span></div>') +
tile('<div class="staged-file"><span class="icon">description</span><span class="small ellipsis">workshop-clip.mp4</span></div>') +
tile('<img src="' + mk('#3a8fe0', '#4a2f9a') + '">') + '</div>'
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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#e0a43a"/><circle cx="16" cy="13" r="6" fill="#fff"/><rect x="6" y="21" width="20" height="12" rx="6" fill="#fff"/></svg>')
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('<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><defs><linearGradient id="g"><stop offset="0" stop-color="' + a + '"/><stop offset="1" stop-color="' + b + '"/></linearGradient></defs><rect width="320" height="180" fill="url(#g)"/><text x="160" y="100" font-size="28" font-family="sans-serif" fill="#fff" text-anchor="middle">' + l + '</text></svg>')
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('<svg xmlns="http://www.w3.org/2000/svg" width="144" height="144"><rect width="144" height="144" fill="' + a + '"/><rect y="104" width="144" height="40" fill="' + b + '"/><text x="72" y="64" font-size="22" font-family="sans-serif" fill="#222" text-anchor="middle">' + l + '</text></svg>')
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 `<figure><figcaption>${label}</figcaption><p class="none">not captured</p></figure>`
const src = `file://${file}`
const media = name.endsWith('.mp4')
? `<video src="${src}" controls loop muted autoplay playsinline></video>`
: `<a href="${src}"><img src="${src}" alt="${label} ${name}"></a>`
return `<figure><figcaption>${label}</figcaption>${media}</figure>`
}
const html = `<!doctype html><meta charset="utf-8"><title>moho UI: ${a} / ${b}</title>
<style>
:root { color-scheme: dark; --bg:#101418; --fg:#e0e2e8; --muted:#949ba4; --line:#2a2e34 }
body { margin:0; padding:24px 16px; background:var(--bg); color:var(--fg); font:14px/1.5 system-ui, sans-serif }
h1 { font-size:20px; margin:0 0 4px } p.sub { color:var(--muted); margin:0 0 24px }
section { border-top:1px solid var(--line); padding:16px 0 }
h2 { font-size:15px; margin:0 0 12px; font-family:ui-monospace, monospace }
.pair { display:grid; grid-template-columns:repeat(auto-fit, minmax(420px, 1fr)); gap:16px }
figure { margin:0; min-width:0 } figcaption { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px }
img, video { width:100%; border:1px solid var(--line); border-radius:6px; display:block }
.none { color:var(--muted); font-style:italic }
</style>
<h1>moho UI, ${a} and ${b}</h1><p class="sub">${names.length} captures, ${new Date().toLocaleString()}</p>
${names.map((n) => `<section><h2>${n.replace(/\.(png|mp4)$/, '')}</h2><div class="pair">${cell(left, a, n)}${cell(right, b, n)}</div></section>`).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)
})