// 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('/ and / 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) })