Files
bare-operating-system/packages/bare-os-booter/test.tui-sdk.js
T
2026-08-18 18:11:34 -04:00

631 lines
16 KiB
JavaScript

import test from 'brittle'
import { attachBareOsTuiSdk, bareOsTuiEnabled } from './lib/services/bare-os-tui-sdk.js'
import { BARE_OS_TUI_SDK_SOURCE } from './lib/services/bare-os-tui-sdk.data.mjs'
import { buildBareOsRuntimeCaps } from './lib/ctx/bare-os-runtime-caps.js'
function makeStream(isTTY, writes) {
const listeners = { data: [], resize: [] }
return {
isTTY: !!isTTY,
raw: false,
columns: 80,
rows: 24,
chunks: writes,
write(s) {
writes.push(String(s))
},
setRawMode(v) {
this.raw = !!v
},
resume() {},
pause() {},
on(ev, fn) {
if (!listeners[ev]) listeners[ev] = []
listeners[ev].push(fn)
},
removeListener(ev, fn) {
const list = listeners[ev]
if (!list) return
const i = list.indexOf(fn)
if (i >= 0) list.splice(i, 1)
},
emit(ev, data) {
const list = listeners[ev] || []
for (const fn of list) fn(data)
}
}
}
function makeCtx(env, opts) {
const writes = []
const tty = opts && opts.tty === false ? false : true
const stdin = makeStream(tty, writes)
const stdout = makeStream(tty, writes)
let suspends = 0
let resumes = 0
return {
env: env || {},
console: { error() {}, log() {} },
replStdin: stdin,
replStdout: stdout,
suspendReplForSubprocess() {
suspends++
},
resumeReplAfterSubprocess() {
resumes++
},
_counts: () => ({
get suspends() {
return suspends
},
get resumes() {
return resumes
},
writes,
stdin,
stdout
})
}
}
test('bareOsTuiEnabled defaults on', (t) => {
t.ok(bareOsTuiEnabled({}))
t.ok(bareOsTuiEnabled({ BARE_OS_TUI: '1' }))
t.absent(bareOsTuiEnabled({ BARE_OS_TUI: '0' }))
t.absent(bareOsTuiEnabled({ BARE_OS_TUI: 'false' }))
})
test('runtime cap tuiSdk follows BARE_OS_TUI', (t) => {
const on = buildBareOsRuntimeCaps({})
t.ok(on.features.tuiSdk)
const off = buildBareOsRuntimeCaps({ BARE_OS_TUI: '0' })
t.absent(off.features.tuiSdk)
})
test('attachBareOsTuiSdk installs ctx.tui and ctx.sdk', (t) => {
t.ok(
typeof BARE_OS_TUI_SDK_SOURCE === 'string' &&
BARE_OS_TUI_SDK_SOURCE.length > 100
)
const ctx = makeCtx({})
t.ok(attachBareOsTuiSdk(ctx))
t.ok(ctx.tui)
t.ok(ctx.sdk)
t.is(ctx.sdk.tui, ctx.tui)
t.is(ctx.tui.version, '1.3.0')
t.ok(ctx.tui.isTTY())
t.is(ctx.tui.size().width, 80)
t.is(ctx.tui.size().height, 24)
t.ok(ctx.tui.ansi.enterAltScreen.indexOf('1049h') >= 0)
})
test('attachBareOsTuiSdk skipped when BARE_OS_TUI=0', (t) => {
const ctx = makeCtx({ BARE_OS_TUI: '0' })
t.absent(attachBareOsTuiSdk(ctx))
t.absent(ctx.tui)
})
test('attachBareOsTuiSdk honors bareOsIsCtxMethodAllowed', (t) => {
const ctx = makeCtx({})
ctx.bareOsIsCtxMethodAllowed = (name) => name !== 'tui'
t.absent(attachBareOsTuiSdk(ctx))
t.absent(ctx.tui)
})
test('decoder: arrows, ctrl+c, enter, utf-8', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const up = ctx.tui.decode('\x1b[A')
t.is(up.length, 1)
t.ok(ctx.tui.key.matches(up[0], 'up'))
const cc = ctx.tui.decode('\x03')
t.ok(ctx.tui.key.matches(cc[0], 'ctrl+c'))
const ent = ctx.tui.decode('\r')
t.ok(ctx.tui.key.matches(ent[0], 'enter'))
const hi = ctx.tui.decode('é')
t.is(hi[0].name, 'é')
})
test('decoder: SGR mouse and bracketed paste', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const mouse = ctx.tui.decode('\x1b[<0;10;5M')
t.is(mouse[0].type, 'mouse')
t.is(mouse[0].action, 'press')
t.is(mouse[0].button, 'left')
t.is(mouse[0].x, 10)
t.is(mouse[0].y, 5)
const paste = ctx.tui.decode('\x1b[200~hello\x1b[201~')
t.is(paste[0].type, 'paste')
t.is(paste[0].text, 'hello')
})
test('decoder: incomplete ESC then flushEscape', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const d = ctx.tui.createDecoder()
d.push('\x1b')
t.is(d.take().length, 0)
const esc = d.flushEscape()
t.ok(ctx.tui.key.matches(esc, 'escape'))
})
test('withSession acquire/release and restore on throw', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const c = ctx._counts()
let threw = false
try {
await ctx.tui.withSession(() => {
t.ok(c.stdin.raw)
t.ok(c.writes.join('').indexOf('1049h') >= 0)
throw new Error('boom')
})
} catch (e) {
threw = e.message === 'boom'
}
t.ok(threw)
t.is(c.suspends, 1)
t.is(c.resumes, 1)
t.absent(c.stdin.raw)
const out = c.writes.join('')
t.ok(out.indexOf('1049l') >= 0)
t.ok(out.indexOf('?25h') >= 0)
})
test('nested acquire is refcounted', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const c = ctx._counts()
ctx.tui.acquire()
ctx.tui.acquire()
t.is(c.suspends, 1)
t.ok(c.stdin.raw)
ctx.tui.release()
t.is(c.resumes, 0)
t.ok(c.stdin.raw)
ctx.tui.release()
t.is(c.resumes, 1)
t.absent(c.stdin.raw)
})
test('captured stdout is not a TTY', (t) => {
const ctx = makeCtx({})
ctx.bareOsStdoutCaptured = true
attachBareOsTuiSdk(ctx)
t.absent(ctx.tui.isTTY())
})
test('style width is ANSI- and wide-glyph aware', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
t.is(ctx.tui.style.width('hi'), 2)
t.is(ctx.tui.style.width('\x1b[32mhi\x1b[0m'), 2)
t.is(ctx.tui.style.width('中'), 2)
t.is(ctx.tui.style.stripAnsi('\x1b[32mhi\x1b[0m'), 'hi')
t.is(ctx.tui.style.height('a\nb\nc'), 3)
const boxed = ctx.tui
.style()
.border(ctx.tui.style.borders.rounded)
.render('hi')
t.ok(boxed.indexOf('hi') >= 0)
t.ok(ctx.tui.style.height(boxed) >= 3)
const joined = ctx.tui.style.joinHorizontal(
ctx.tui.style.position.top,
'A',
'B'
)
t.is(joined, 'AB')
})
test('NO_COLOR skips SGR in style.render', (t) => {
const ctx = makeCtx({ NO_COLOR: '1' })
attachBareOsTuiSdk(ctx)
const painted = ctx.tui.style().bold(true).foreground('cyan').render('hi')
t.is(painted, 'hi')
t.ok(ctx.tui.theme().noColor)
})
test('theme name follows BARE_OS_THEME', (t) => {
const ctx = makeCtx({ BARE_OS_THEME: 'nord' })
attachBareOsTuiSdk(ctx)
t.is(ctx.tui.theme().name, 'nord')
t.is(ctx.sdk.theme.name(), 'nord')
t.ok(ctx.tui.theme().tokens.accent)
})
test('Program counter: up increments then quit', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = {
n: 0,
init() {
return null
},
update(msg) {
if (ctx.tui.key.matches(msg, 'up', 'k')) this.n++
if (ctx.tui.key.matches(msg, 'q', 'ctrl+c')) return [this, ctx.tui.quit]
return [this, null]
},
view() {
return 'count: ' + this.n
}
}
const p = ctx.tui.create(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0,
width: 40,
height: 8
})
const up = ctx.tui.decode('\x1b[A')[0]
p.send(up)
p.send(up)
p.quit()
await p.run()
t.is(app.n, 2)
t.ok(ctx._counts().writes.join('').indexOf('count: 2') >= 0)
t.ok(ctx._counts().writes.join('').indexOf('1049l') >= 0)
t.is(ctx.sdk.tui, ctx.tui)
})
test('Program restores terminal when update throws', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = {
update() {
throw new Error('bad update')
},
view() {
return 'x'
}
}
let err = null
try {
await ctx.tui.run(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0
})
} catch (e) {
err = e
}
t.ok(err && err.message === 'bad update')
const out = ctx._counts().writes.join('')
t.ok(out.indexOf('1049l') >= 0)
t.ok(out.indexOf('?25h') >= 0)
t.absent(ctx._counts().stdin.raw)
})
test('Program refuses a non-TTY without allowDumb', async (t) => {
const ctx = makeCtx({}, { tty: false })
attachBareOsTuiSdk(ctx)
const app = {
update() {
return [this, ctx.tui.quit]
},
view() {
return 'x'
}
}
await ctx.tui.run(app, { fps: 0 })
t.is(ctx.exitCode, 1)
})
test('decoder names space as space', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const ev = ctx.tui.decode(' ')[0]
t.ok(ctx.tui.key.matches(ev, 'space'))
})
test('widgets: checkbox toggle and list select', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const box = ctx.tui.checkbox.create({ label: 'ok' }).focus()
const space = ctx.tui.decode(' ')[0]
box.update(space)
t.ok(box.checked)
t.ok(box.view().indexOf('[x]') >= 0)
const list = ctx.tui.list.create({
items: ['alpha', 'beta', 'gamma'],
height: 3,
filterable: false
})
const down = ctx.tui.decode('\x1b[B')[0]
list.update(down)
t.is(list.selectedItem(), 'beta')
t.ok(list.view().indexOf('beta') >= 0)
})
test('widgets: textinput insert and progress view', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const field = ctx.tui.textinput.create({ focused: true })
field.update(ctx.tui.decode('a')[0])
field.update(ctx.tui.decode('b')[0])
t.is(field.value, 'ab')
const bar = ctx.tui.progress.create({ width: 12 })
const v = bar.view(0.5)
t.ok(v.indexOf('50%') >= 0)
})
test('widgets: focus ring tabs between fields', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const a = ctx.tui.textinput.create({ prompt: 'A' })
const b = ctx.tui.checkbox.create({ label: 'B' })
const ring = ctx.tui.focus.create({ items: [a, b] })
t.ok(ring.focused().focused)
const tab = ctx.tui.decode('\t')[0]
ring.update(tab)
t.ok(ring.items[1].focused)
t.absent(ring.items[0].focused)
})
test('tick command fires a message then quit', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = {
ticks: 0,
init() {
return ctx.tui.tick(1, function () {
return { type: 'tock' }
})
},
update(msg) {
if (msg && msg.type === 'tock') {
this.ticks++
return [this, ctx.tui.quit]
}
return [this, null]
},
view() {
return 't:' + this.ticks
}
}
await ctx.tui.run(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0
})
t.is(app.ticks, 1)
})
test('filepicker mock lists and selects a file', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const m = ctx.tui.filepicker.mock({
docs: { 'a.md': 1 },
'r.txt': 1
})
const fp = ctx.tui.filepicker.create({ vfs: m.vfs, cwd: '/', height: 6 })
const listed = await fp.init()()
fp.update(listed)
t.ok(fp.entries.length >= 2)
fp.update(ctx.tui.decode('\x1b[B')[0])
const pair = fp.update(ctx.tui.decode('\r')[0])
t.ok(typeof pair[1] === 'function')
const sel = await pair[1]()
t.is(sel.type, 'filepicker.select')
t.is(sel.path, '/r.txt')
})
test('confirm model accepts y', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = ctx.tui.confirm.create({ title: 'Delete?' })
const p = ctx.tui.create(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0
})
p.send(ctx.tui.decode('y')[0])
await p.run()
t.ok(app.result)
})
test('prompt model collects text', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = ctx.tui.prompt.create({ label: 'Name: ' })
const p = ctx.tui.create(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0
})
p.send(ctx.tui.decode('a')[0])
p.send(ctx.tui.decode('\r')[0])
await p.run()
t.is(app.result, 'a')
})
test('form required + ctrl+s submits values', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const f = ctx.tui.form.create({
title: 'Account',
fields: [
ctx.tui.form.text({ name: 'user', label: 'User', required: true }),
ctx.tui.form.confirm({ name: 'tos', label: 'TOS', required: true })
]
})
const fail = f.update(ctx.tui.decode('\x13')[0])
t.absent(fail[1])
t.ok(f.errors().user)
f.update(ctx.tui.decode('z')[0])
f.update(ctx.tui.decode('\t')[0])
f.update(ctx.tui.decode(' ')[0])
const ok = f.update(ctx.tui.decode('\x13')[0])
t.ok(typeof ok[1] === 'function')
const msg = ok[1]()
t.is(msg.type, 'form.submit')
t.is(msg.values.user, 'z')
t.ok(msg.values.tos)
})
test('choose model picks an option', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = ctx.tui.choose.create({
title: 'Plan',
options: ['free', 'pro']
})
const p = ctx.tui.create(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0
})
p.send(ctx.tui.decode('\x1b[B')[0])
p.send(ctx.tui.decode('\r')[0])
await p.run()
t.is(app.result, 'pro')
})
test('markdown subset renders headings and lists', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const html = ctx.tui.markdown.render('# Title\n- item\n**bold**')
t.ok(html.indexOf('Title') >= 0)
t.ok(html.indexOf('• ') >= 0)
t.ok(html.indexOf('bold') >= 0)
})
test('tabs switch with arrows', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const tabs = ctx.tui.tabs.create({
tabs: [
{ title: 'A', body: 'one' },
{ title: 'B', body: 'two' }
]
})
t.ok(tabs.view().indexOf('one') >= 0)
tabs.update(ctx.tui.decode('\x1b[C')[0])
t.is(tabs.index, 1)
t.ok(tabs.view().indexOf('two') >= 0)
})
test('sdk env / ipc / proc helpers', async (t) => {
const ctx = makeCtx({ TERM: 'xterm-256color', NO_COLOR: '' })
ctx.env.HOME = '/home/guest'
ctx.vfs = {
async readdir() {
return ['a']
},
async readFile(p) {
if (String(p).indexOf('version') >= 0) return new Uint8Array([49])
return null
}
}
ctx.b4a = {
toString(buf) {
return Buffer.from(buf).toString()
},
from(s) {
return Buffer.from(String(s))
}
}
let pushed = null
ctx.bareOsIpc = {
pushJson(name, obj) {
pushed = { name, obj }
},
async takeJson() {
return pushed && pushed.obj
}
}
attachBareOsTuiSdk(ctx)
t.is(ctx.sdk.env.get('TERM'), 'xterm-256color')
t.is(ctx.sdk.env.term(), 'xterm-256color')
t.ok(ctx.sdk.env.colorDepth())
t.ok(ctx.sdk.theme.name())
t.ok(ctx.sdk.ipc.pushJson('ch', { a: 1 }))
t.is((await ctx.sdk.ipc.takeJson('ch')).a, 1)
t.is(await ctx.sdk.proc.read('version'), '1')
t.alike(await ctx.sdk.vfs.list('/'), ['a'])
})
test('cell buffer blit does not change row count', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const grid = ctx.tui.buffer.create(5, 20)
ctx.tui.buffer.fill(grid, 'one\ntwo\nthree\nfour\nfive')
t.is(ctx.tui.buffer.plain(grid).length, 5)
ctx.tui.buffer.blit(grid, 1, 2, 'POP')
const lines = ctx.tui.buffer.plain(grid)
t.is(lines.length, 5)
t.ok(lines[1].indexOf('POP') >= 0)
t.is(lines[0], 'one')
t.is(lines[4], 'five')
})
test('Program cell buffer paints overlay without extra lines', async (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const app = {
open: true,
init() {
return null
},
update(msg) {
if (ctx.tui.key.matches(msg, 'q')) return [this, ctx.tui.quit]
return [this, null]
},
view() {
return 'AAAA\nBBBB\nCCCC\nDDDD'
},
overlay() {
if (!this.open) return null
return { row: 1, col: 1, text: 'XY' }
}
}
const p = ctx.tui.create(app, {
input: ctx.replStdin,
output: ctx.replStdout,
isTTY: true,
fps: 0,
buffer: 'cell',
width: 8,
height: 4
})
p.quit()
await p.run()
t.ok(p.renderer.plain()[1].indexOf('XY') >= 0)
t.is(p.renderer.plain().length, 4)
})
test('modal overlay() is centered and view stays child', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const child = {
view() {
return 'body-line-1\nbody-line-2\nbody-line-3'
}
}
const modal = ctx.tui.modal.create({ child: child, open: true, title: 'Ask' })
t.is(modal.view(), child.view())
const ov = modal.overlay({ width: 40, height: 12 })
t.ok(ov)
t.ok(ov.row >= 0)
t.ok(ov.text.indexOf('Ask') >= 0)
})
test('tree expands children', (t) => {
const ctx = makeCtx({})
attachBareOsTuiSdk(ctx)
const tree = ctx.tui.tree.create({
items: [{ title: 'root', children: [{ title: 'leaf' }], open: false }]
})
t.absent(tree.view().indexOf('leaf') >= 0)
tree.update(ctx.tui.decode('\x1b[C')[0])
t.ok(tree.view().indexOf('leaf') >= 0)
})