// A look at the pages as this checkout draws them: not what moved, as // compare.mjs says, but what a page is, in any state, as pictures to open. // The pages are drawn from web/build in front of fixtures.json's answers, // as the shots are, so no PDS is needed. // // node harness/shots/look.mjs ... [options] `just look` // // A page is a name from fixtures.json's views (home, pull-open, ...) or a // path. A path whose answers were not recorded is answered from the same // method's answer for another path, which is said. Options: // // --set .= change what a method answers, as // getFeed.events=[] or // getRepo.repo.description="..."; a // value that is not JSON is a string, // and @file.json is the file's JSON; // null takes the field out // --signed-in as alice.test, logged in // --as as that account, logged in // --click click it first (a menu, a tab), in turn; // then only the screen is shot, not the page // --type = type into it, emptied, in turn with the clicks, // and wait for what that asks for // --hover hover over it last // --hold never answer what the page asks of it // (as inviteToOrg), so what a click // starts is shot still under way // --theme in that theme, as /settings/appearance // chooses (dracula, latte, ...) // --element only it, not the whole page // --desktop --phone --light --dark only those; desktop light and // phone dark if none is given // --all all four // --webkit in WebKit, as Safari and every // browser on an iPhone draw them, not // Chromium: its pictures end -webkit // // An answer --set changed must still be as its method's lexicon says, as // the server's must: one that is not is said, and the run fails, so a // page is never judged on an answer the server could not give. // A page wider than the screen is said, with what overflows it. // The pictures go to target/look, each path printed to open; a run // writes over the pictures of the same page and project, and no others. import { chromium, webkit } from '@playwright/test' import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { crc32 } from 'node:zlib' import { front, recording } from './fixtures.mjs' import config from './playwright.config.mjs' import { base, now, root } from './world.mjs' const out = join(root, 'target/look') const say = (line) => console.log(`==> ${line}`) const fail = (why) => { say(why) process.exit(2) } const targets = [] const sets = [] const clicks = [] const holds = [] const sides = new Set() let hover let element let as = null let theme = null let inWebkit = false const argv = process.argv.slice(2) for (let i = 0; i < argv.length; i++) { const a = argv[i] const next = () => argv[++i] ?? fail(`${a} needs a value`) if (a === '--set') { const v = next() const [, method, path, value] = v.match(/^([^.=]+)\.([^=]+)=(.*)$/s) ?? fail(`--set ${v}: not .=`) let parsed if (value.startsWith('@')) parsed = JSON.parse(readFileSync(value.slice(1), 'utf8')) else { try { parsed = JSON.parse(value) } catch { parsed = value } } sets.push({ method, path: path.split('.'), value: parsed }) } else if (a === '--signed-in') as = 'alice.test' else if (a === '--as') as = next() else if (a === '--click') clicks.push(next()) else if (a === '--type') { const [, sel, text] = next().match(/^([^=]+)=(.*)$/s) ?? fail('--type takes =') clicks.push({ sel, text }) } else if (a === '--hover') hover = next() else if (a === '--hold') holds.push(next()) else if (a === '--theme') theme = next() else if (a === '--element') element = next() else if (a === '--webkit') inWebkit = true else if (a === '--all') for (const s of ['desktop', 'phone', 'light', 'dark']) sides.add(s) else if (/^--(desktop|phone|light|dark)$/.test(a)) sides.add(a.slice(2)) else if (a.startsWith('--')) fail(`${a}: no such option`) else targets.push(a) } const { views, answers } = recording() ?? fail('no fixtures.json: `just shots-record`') if (!targets.length) fail(`which page? one of ${Object.keys(views).join(', ')}, or a path`) for (const t of targets) if (!t.startsWith('/') && !views[t]) fail(`${t}: no such page; one of ${Object.keys(views).join(', ')}, or a path`) // The projects shot: of each kind given, those; desktop light and phone // dark when none is, one of each width and each scheme. const widths = ['desktop', 'phone'].filter((s) => sides.has(s)) const schemes = ['light', 'dark'].filter((s) => sides.has(s)) const projects = !widths.length && !schemes.length ? ['desktop-light', 'phone-dark'] : (widths.length ? widths : ['desktop', 'phone']).flatMap((w) => (schemes.length ? schemes : ['light', 'dark']).map((s) => `${w}-${s}`)) if (as) { // Its DID, as some recorded answer gives it with its handle. const handle = new RegExp(`"did":"(did:[^"]+)","handle":"${as.replace(/[.]/g, '\\.')}"`) const did = Object.values(answers).map((a) => a.body?.match(handle)?.[1]).find(Boolean) if (!did) fail(`--as ${as}: no recorded answer names that handle`) const quiet = true sets.push({ method: 'getViewer', path: ['actor'], value: { did, handle: as }, quiet }) sets.push({ method: 'getViewer', path: ['login'], value: true, quiet }) } // The lexicons, to hold a changed answer to its method's. const { Lexicons } = createRequire(import.meta.url)('@atproto/lexicon') const schemas = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? schemas(join(d, e.name)) : e.name.endsWith('.json') ? [JSON.parse(readFileSync(join(d, e.name), 'utf8'))] : [], ) const lexicons = new Lexicons(schemas(join(root, 'lexicons'))) // The methods whose changed answer was not as its lexicon says, each said once. const invalid = new Set() // The --set fields an answer did not have, which then changed nothing the // page reads: each said once, with the fields there were. const missed = new Set() /// `body` with each of `sets` for `method` made. /** `png` saying, as a Mac's screenshot does, that it was made at `scale` * times 72 pixels to the inch: a pull or issue it is attached to then * draws it at the size it was seen, not at its pixels. */ function dense(png, scale) { const chunk = Buffer.alloc(13) chunk.write('pHYs') const perMetre = Math.round((scale * 72) / 0.0254) chunk.writeUInt32BE(perMetre, 4) chunk.writeUInt32BE(perMetre, 8) chunk[12] = 1 const len = Buffer.alloc(4) len.writeUInt32BE(9) const crc = Buffer.alloc(4) crc.writeUInt32BE(crc32(chunk)) // After the signature and the header, which come first. return Buffer.concat([png.subarray(0, 33), len, chunk, crc, png.subarray(33)]) } function changed(method, body) { for (const s of sets.filter((s) => method.endsWith(`.${s.method}`))) { let at = body for (const [i, key] of s.path.entries()) { const name = `${s.method}.${s.path.slice(0, i + 1).join('.')}` if (at[key] === undefined && !s.quiet && !missed.has(name)) { missed.add(name) say(`--set ${name}: no such field, so it is added; there were ${Object.keys(at).join(', ') || 'none'}`) } if (i === s.path.length - 1 && s.value === null) delete at[key] else if (i === s.path.length - 1) at[key] = s.value else at = at[key] ??= {} } } return body } const said = new Set() // The methods the pages asked, and the --set ones that changed an answer. const asked = new Set() const used = new Set() // What the stand-in answers: as recorded, else as the method answered // another path, else nothing; with what --set changes. const recorded = new Proxy(answers, { get(all, path) { if (typeof path !== 'string') return undefined const method = path.match(/^\/xrpc\/([^?]+)/)?.[1] if (method) asked.add(method.split('.').at(-1)) let a = all[path] if (!a && method) { const like = Object.keys(all).find((p) => p.split('?')[0] === `/xrpc/${method}`) if (like) { a = all[like] if (!said.has(path)) say(`${path} was not recorded: answered as ${like}`) said.add(path) } } const these = method ? sets.filter((s) => method.endsWith(`.${s.method}`)) : [] if (!these.length) return a for (const s of these) used.add(s.method) a ??= { status: 200, headers: { 'content-type': 'application/json', 'cache-control': 'no-cache' }, body: '{}' } const body = changed(method, JSON.parse(a.body)) if (a.status === 200 && !invalid.has(method)) { try { lexicons.assertValidXrpcOutput(method, body) } catch (e) { invalid.add(method) say(`--set ${method}: the answer is not as its lexicon says, so no server could give it: ${e.message}`) } } return { ...a, body: JSON.stringify(body) } }, }) const named = (t) => (t.startsWith('/') ? t.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home' : t) mkdirSync(out, { recursive: true }) const stand = await front({ recorded }) // Chromium's launch options are its own. const browser = inWebkit ? await webkit.launch() : await chromium.launch(config.use.launchOptions) let shot = 0 try { for (const project of projects) { const use = config.projects.find((p) => p.name === project).use const context = await browser.newContext({ ...use, locale: config.use.locale, timezoneId: config.use.timezoneId, baseURL: base }) await context.addCookies([{ name: 'tz', value: encodeURIComponent(config.use.timezoneId), url: base }]) if (theme) await context.addCookies([{ name: 'theme', value: theme, url: base }]) // Signed in for the stand-in too, which answers what only a login has // (fixtures.mjs), as views.spec.mjs signs a view in. if (as) await context.addCookies([{ name: 'shots_as', value: encodeURIComponent(as), url: base }]) for (const target of targets) { const page = await context.newPage() await page.clock.setFixedTime(new Date(now)) await page.goto(views[target] ?? target, { waitUntil: 'load' }) // As views.spec.mjs waits: highlighting, diagrams, what comes after. await page.locator('html[data-ready]').waitFor({ state: 'attached' }) await page.waitForFunction(() => !document.querySelector('[data-drawing]')) await page.waitForFunction(() => !document.querySelector('[data-loading]')) // Left unanswered, a write stays under way while it is shot. for (const h of holds) await page.route((url) => url.pathname.endsWith(`.${h}`), () => {}) // Each in the middle of the screen, so what it opens is in the picture. for (const c of clicks) { const it = page.locator(c.sel ?? c).first() await it.evaluate((e) => e.scrollIntoView({ block: 'center' })) if (c.sel) { // In place of what the field held, as a name to rename to is. await it.fill('') await it.pressSequentially(c.text) // A field that looks up what is typed waits a moment first. await page.waitForTimeout(400) await page.waitForLoadState('networkidle') } else await it.click() } if (hover) await page.locator(hover).first().hover() // A page wider than the screen scrolls sideways, which a picture of // the whole page hides: said, with the innermost elements whose box or // text runs past the screen's edge, which is where to look. const wide = await page.evaluate(() => { const w = document.documentElement.clientWidth if (document.documentElement.scrollWidth <= w) return null const past = (e) => { const r = e.getBoundingClientRect() return r.right > w + 1 || (getComputedStyle(e).overflowX === 'visible' && r.left + e.scrollWidth > w + 1) } const over = [...document.body.querySelectorAll('*')].filter((e) => !e.closest('details:not([open]) > :not(summary)') && past(e)) const inner = over.filter((e) => !over.some((o) => o !== e && e.contains(o))) return { by: document.documentElement.scrollWidth - w, at: inner.slice(0, 3).map((e) => `${e.tagName.toLowerCase()}${[...e.classList].map((c) => `.${c}`).join('')} "${e.textContent.trim().slice(0, 30)}"`) } }) if (wide) say(`${named(target)}-${project}: ${wide.by}px wider than the screen, at ${wide.at.join(', ')}`) const file = join(out, `${named(target)}-${project}${inWebkit ? '-webkit' : ''}.png`) const opts = { path: file, animations: 'disabled', caret: 'hide' } if (element) await page.locator(element).first().screenshot(opts) // With a menu opened, what is on the screen: a full page draws what is // fixed to the screen's bottom, as a menu is on a phone, partway down. else await page.screenshot({ ...opts, fullPage: !clicks.length && !hover }) const scale = use.deviceScaleFactor ?? 1 if (scale > 1) writeFileSync(file, dense(readFileSync(file), scale)) console.log(file) shot++ await page.close() } await context.close() } } finally { await browser.close() await stand.kill() } say(`the pages asked ${[...asked].sort().join(', ')}`) const unused = [...new Set(sets.map((s) => s.method))].filter((m) => !used.has(m)) for (const m of unused) say(`--set ${m}: no page asked for it, so it changed nothing`) say(`${shot} pictures in ${out}`) process.exit(unused.length || invalid.size ? 1 : 0)