~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
172 lines · 9.9 KB Raw History
// node --test: the page in headless Chrome, served from this checkout on 127.0.0.1.
// With ALICE_ATPROTO_HANDLE and ALICE_ATPROTO_PASSWORD (an app password): a publish and bind round as that
// account, every record deleted after.
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { extname, join } from 'node:path'
import { after, before, test } from 'node:test'

const CHROME = [process.env.CHROME, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/chromium'].find((p) => p && existsSync(p))
const ROOT = new URL('..', import.meta.url).pathname
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.json': 'application/json' }
let server, chrome, profile, ws, origin

const send = (() => {
	let id = 0
	const waiting = new Map()
	return Object.assign(
		(method, params = {}) =>
			new Promise((ok, fail) => {
				waiting.set(++id, (msg) => (msg.error ? fail(new Error(`${method}: ${msg.error.message}`)) : ok(msg.result)))
				ws.send(JSON.stringify({ id, method, params }))
			}),
		{ reply: (msg) => waiting.get(msg.id)?.(msg) },
	)
})()

async function evaluate(expression) {
	const { result, exceptionDetails } = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
	if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text)
	return result.value
}

// The #status text once it is no longer "checking…" (or empty).
const status = () => evaluate(`new Promise((ok) => { const t = setInterval(() => { const s = document.querySelector('#status')?.textContent; if (s && s !== 'checking…') { clearInterval(t); ok(s) } }, 100) })`)
const setText = (text) => evaluate(`import('codemirror').then(({ EditorView }) => { document.querySelector('#status').textContent = ''; const v = EditorView.findFromDOM(document.querySelector('.cm-editor')); v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: ${JSON.stringify(text)} } }) })`)

before(async () => {
	server = createServer(async (req, res) => {
		const path = decodeURIComponent(new URL(req.url, 'http://x').pathname)
		const body = await readFile(join(ROOT, path === '/' ? 'index.html' : path)).catch(() => null)
		if (!body) return res.writeHead(404).end()
		res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'text/html; charset=utf-8' }).end(body)
	})
	await new Promise((ok) => server.listen(0, '127.0.0.1', ok))
	origin = `http://127.0.0.1:${server.address().port}`
	if (!CHROME) return
	profile = mkdtempSync(join(tmpdir(), 'editor-chrome-'))
	chrome = spawn(CHROME, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
	process.once('exit', () => chrome.kill())
	const port = await new Promise((ok, fail) => {
		let err = ''
		chrome.stderr.on('data', (d) => {
			err += d
			const m = /DevTools listening on ws:\/\/[^:]+:(\d+)\//.exec(err)
			if (m) ok(m[1])
		})
		chrome.on('exit', () => fail(new Error(`chrome exited:\n${err}`)))
	})
	const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
	ws = new WebSocket(targets.find((t) => t.type === 'page').webSocketDebuggerUrl)
	await new Promise((ok) => ws.addEventListener('open', ok, { once: true }))
	ws.addEventListener('message', ({ data }) => send.reply(JSON.parse(data)))
	await send('Page.navigate', { url: `${origin}/` })
})

after(async () => {
	ws?.close()
	if (chrome && chrome.exitCode === null) await new Promise((ok) => chrome.once('exit', ok).kill())
	if (profile) rmSync(profile, { recursive: true, force: true })
	server?.close()
})

test('boots: the example loads its modules and checks valid', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	assert.equal(await status(), 'valid')
	assert.deepEqual(await evaluate(`[...document.querySelectorAll('#summary h3')].map((h) => h.textContent)`), ['Instances', 'Edges', 'Hooks', 'Parameters', 'Exports'])
	assert.equal(await evaluate(`document.querySelectorAll('#summary table')[0].rows.length`), 4)
})

test('links its source repo', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	assert.equal(await evaluate(`document.querySelector('a#source')?.href`), 'https://knotless.dev/johnandersen777.bsky.social/atlibs-web-editor-ui')
})

test('live validation: the lexicon, then the load checks', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	const example = JSON.parse(await readFile(join(ROOT, 'example.json'), 'utf8'))
	await setText(JSON.stringify({ ...example, name: undefined }))
	assert.match(await status(), /name/)
	await setText(JSON.stringify({ ...example, edges: [{ ...example.edges[0], to: 'nope' }] }))
	assert.match(await status(), /to is not an instance/)
	await setText(JSON.stringify({ ...example, instances: example.instances.map((i) => ({ ...i, pin: 'a'.repeat(64) })) }))
	assert.match(await status(), /does not match its pin/)
	await setText(JSON.stringify({ ...example, parameters: [{ key: 'n', type: 'integer', maximum: 100, values: { default: 200 } }] }))
	assert.match(await status(), /at most 100/)
	await setText(JSON.stringify(example))
	assert.equal(await status(), 'valid')
})

test('<did>/<name> and <handle>/<name> are the same module; shown by handle', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	const got = await evaluate(`import('/lib.js').then(async (lib) => {
		const [byDid, byHandle] = await Promise.all(['did:plc:ocnuqjlzgohypj3zoznermms/compose', 'bobvmbuilder.bsky.social/compose'].map(lib.moduleRef))
		return { byDid, byHandle, label: await lib.label(byDid.uri), table: document.querySelector('#summary table td:nth-child(2)').textContent }
	})`)
	assert.deepEqual(got.byHandle, got.byDid)
	assert.equal(got.byDid.uri, 'at://did:plc:ocnuqjlzgohypj3zoznermms/com.fedfork.exec.wasm.module/compose')
	assert.equal(got.label, 'bobvmbuilder.bsky.social/compose')
	assert.equal(got.table, 'bobvmbuilder.bsky.social/compose')
})

const alice = process.env.ALICE_ATPROTO_HANDLE && process.env.ALICE_ATPROTO_PASSWORD
test('publish, read back, bind, delete: as ALICE_ATPROTO_HANDLE', { skip: (!CHROME && 'no Chrome') || (!alice && 'no ALICE_ATPROTO_HANDLE/ALICE_ATPROTO_PASSWORD') }, async () => {
	const r = await fetch('https://bsky.social/xrpc/com.atproto.server.createSession', {
		method: 'POST',
		headers: { 'content-type': 'application/json' },
		body: JSON.stringify({ identifier: process.env.ALICE_ATPROTO_HANDLE, password: process.env.ALICE_ATPROTO_PASSWORD }),
	})
	assert.ok(r.ok, `createSession: ${r.status}`)
	const { did, accessJwt, didDoc } = await r.json()
	const pds = didDoc.service.find((s) => s.id === '#atproto_pds').serviceEndpoint
	const rkey = `editor-test-${Date.now()}`
	const round = await evaluate(`(async () => {
		const lib = await import('/lib.js')
		const agent = { did: ${JSON.stringify(did)}, fetchHandler: (path, init) => fetch(${JSON.stringify(pds)} + path, { ...init, headers: { ...init.headers, authorization: 'Bearer ' + ${JSON.stringify(accessJwt)} } }) }
		const example = await (await fetch('/example.json')).json()
		const rkey = ${JSON.stringify(rkey)}
		const out = {}
		try {
			await lib.check(example, agent.did)
			out.put = await lib.putRecord(agent, lib.COMPOSITION, rkey, example)
			const opened = await lib.openComposition(agent.did + '/' + rkey)
			out.read = opened.cid
			await lib.check(opened.value, opened.did)
			out.stale = await lib.putRecord(agent, lib.COMPOSITION, rkey, { ...example, description: 'changed' }, 'bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua').then(() => 'written', (e) => e.message)
			out.bound = await lib.putRecord(agent, lib.BINDING, rkey, lib.binding(opened, { n: 8 }))
			out.bindingCid = (await lib.getRecord({ did: agent.did, collection: lib.BINDING, rkey })).value.composition.cid
			out.badInputs = (() => { try { lib.binding(opened, { n: 200 }) } catch (e) { return e.message } })()
		} finally {
			await lib.deleteRecord(agent, lib.COMPOSITION, rkey)
			await lib.deleteRecord(agent, lib.BINDING, rkey)
		}
		out.gone = await lib.getRecord({ did: agent.did, collection: lib.COMPOSITION, rkey }).then(() => 'still there', (e) => e.message)
		return out
	})()`)
	assert.equal(round.read, round.put.cid)
	assert.match(round.stale, /InvalidSwap/)
	assert.equal(round.bindingCid, round.put.cid)
	assert.match(round.badInputs, /at most 100/)
	assert.match(round.gone, /not found/)
	console.log(`published and deleted ${round.put.uri} @ ${round.put.cid}; binding ${round.bound.uri}`)
})

test('an OAuth callback fragment stays for client.init()', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	await send('Page.navigate', { url: `${origin}/index.html#from-the-authorization-server` })
	assert.equal(await status(), 'valid')
	assert.equal(await evaluate('location.hash'), '#from-the-authorization-server')
})

test('the ref open at sign-in is open again after the callback', { skip: !CHROME && 'no Chrome: set CHROME' }, async () => {
	const uri = 'at://did:plc:ocnuqjlzgohypj3zoznermms/com.fedfork.exec.composition/socialweb-computer-cicd'
	await evaluate(`sessionStorage.setItem('atlibs-editor.ref', ${JSON.stringify(uri)})`)
	await send('Page.navigate', { url: `${origin}/index.html?#from-the-authorization-server` })
	// the old page may answer until the new one loads: wait for the reopened ref, shown by handle
	const shown = 'bobvmbuilder.bsky.social/socialweb-computer-cicd'
	let opened = ''
	for (let i = 0; i < 300 && !opened.startsWith(shown); i++) opened = await evaluate(`new Promise((ok) => setTimeout(() => ok(document.querySelector('#opened')?.textContent ?? ''), 100))`).catch(() => '')
	assert.match(opened, new RegExp(`^${shown} @ bafy`))
	assert.ok(await status())
	assert.equal(await evaluate('location.hash'), '#from-the-authorization-server')
	assert.equal(await evaluate(`sessionStorage.getItem('atlibs-editor.ref')`), null)
})