chadtmiller.com/knotless
git that lives in your Atmosphere account
7
// 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 <page|/path>... [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 <method>.<field>=<json> 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 <handle> as that account, logged in
// --click <selector> click it first (a menu, a tab), in turn;
// then only the screen is shot, not the page
// --type <selector>=<text> type into it, emptied, in turn with the clicks,
// and wait for what that asks for
// --hover <selector> hover over it last
// --hold <method> never answer what the page asks of it
// (as inviteToOrg), so what a click
// starts is shot still under way
// --theme <name> in that theme, as /settings/appearance
// chooses (dracula, latte, ...)
// --element <selector> 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 <method>.<field>=<value>`)
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 <selector>=<text>')
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)