1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
// 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)
})