~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
292 lines · 13.8 KB Raw History
// 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)