Files
omp-edit/test/client.test.mjs
T

109 lines
5.9 KiB
JavaScript

// Smoke test for the Web client: loads client.js against a tiny stub React + primitives and walks
// the rendered tree, so the row/card code paths run without a browser or a React install.
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const SRC = readFileSync(fileURLToPath(new URL('../client.js', import.meta.url)), 'utf8')
function makeReact() {
let slots = []; let i = 0
const React = {
Fragment: Symbol('Fragment'),
createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children: props?.children ?? children.flat() } }),
useState: (init) => { const k = i++; if (!(k in slots)) slots[k] = typeof init === 'function' ? init() : init; return [slots[k], (v) => { slots[k] = typeof v === 'function' ? v(slots[k]) : v }] },
useMemo: (f) => f(),
useCallback: (f) => f,
reset() { slots = []; i = 0 },
rewind() { i = 0 },
}
return React
}
const stub = (name) => Object.assign((p) => ({ type: name, props: p }), { displayName: name })
const P = {
IconEditOutlineRegular: stub('IconEdit'), IconWrapFillRegular: stub('IconWrap'), IconNowrapFillRegular: stub('IconNowrap'),
IconCopyOutlineRegular: stub('IconCopy'), IconCheckOutlineRegular: stub('IconCheck'),
Tooltip: ({ children }) => children,
TextShimmer: ({ children, className }) => ({ type: 'span', props: { className, children } }),
languageForPath: (p) => (p.endsWith('.js') ? 'javascript' : undefined),
writeClipboard: async () => true,
DisclosureRow: (p) => ({ type: 'div', props: { 'data-disclosure': true, children: [p.icon, p.title, p.collapsedContent, p.open ? p.children : null] } }),
}
function load() {
const React = makeReact()
let mod
globalThis.window = { __ModuleLoader__: { load: ({ factory }) => { mod = factory((id) => (id === 'react' ? React : P)) } }, setTimeout }
new Function(SRC)()
let view
mod.apply({ slots: { inject: (_n, f) => { const r = f(); if (r && typeof r.next === 'function') [...r] }, register: (m, C) => { if (m.key === 'edit') view = C } } })
return { React, view }
}
/** Expand function components into host nodes; collect text and class names. */
function render(node, out = { text: [], classes: [] }) {
if (node == null || node === false || node === true) return out
if (Array.isArray(node)) { node.forEach((n) => render(n, out)); return out }
if (typeof node !== 'object') { out.text.push(String(node)); return out }
if (typeof node.type === 'function') return render(node.type(node.props), out)
if (node.props?.className) out.classes.push(...String(node.props.className).split(' '))
return render(node.props?.children, out)
}
const t = (k, p) => ({ 'tool.title.edit': 'Edit', 'row.output': 'Output' }[k] ?? k)
const diffs = [{ path: '/ws/a.js', oldText: 'x\n', newText: 'y\n', oldStart: 5, newStart: 5, rows: [[' ', 'keep'], ['-', 'x'], ['+', 'y']] }]
const result = (extra = {}) => ({ kind: 'tool-result', callId: 'c1', isError: false, content: [{ type: 'text', text: 'ok' }], meta: { diffs }, call: { argsRaw: JSON.stringify({ input: '[a.js#ABCD]\nPUT 6.=6:\n+y' }) }, ...extra })
test('settled edit renders DSH row chrome; card only when expanded', () => {
const { React, view } = load()
let expanded = false
const props = { t, toolName: 'edit', cwd: '/ws', phase: 'result', block: result(), openFile: () => {}, useDisclosure: () => ({ expanded, setExpanded() {}, toggle() { expanded = !expanded } }) }
React.reset()
let out = render(view(props))
assert.ok(out.text.includes('Edit'))
assert.ok(out.text.includes('a.js'), 'workspace-relative file summary')
assert.ok(out.classes.includes('ompe-file'), 'dotted open-file link')
assert.ok(out.text.includes('+1') && out.text.includes('-1'), 'diff stat')
assert.ok(!out.classes.includes('ompe-card'), 'collapsed by default like stock rows')
expanded = true
React.reset()
out = render(view(props))
assert.ok(out.classes.includes('ompe-card'))
assert.ok(out.text.includes('javascript'), 'language header')
for (const n of ['5', '6', 'keep', 'x', 'y']) assert.ok(out.text.includes(n), `row/gutter text ${n}`)
assert.ok(out.classes.includes('ompe-add') && out.classes.includes('ompe-del') && out.classes.includes('ompe-ln'))
})
test('long diffs fold to head/tail with an expand control', () => {
const { React, view } = load()
const rows = Array.from({ length: 30 }, (_, i) => ['+', `line${i}`])
const block = result({ meta: { diffs: [{ path: 'b.txt', oldText: null, newText: '', oldStart: 1, newStart: 1, rows }] } })
React.reset()
const out = render(view({ t, toolName: 'edit', phase: 'result', block, useDisclosure: () => ({ expanded: true, toggle() {} }) }))
assert.ok(out.classes.includes('ompe-expand'))
assert.ok(out.text.includes('line0') && out.text.includes('line29') && !out.text.includes('line15'))
})
test('failed edit shows red first-line summary and Output card, no stat', () => {
const { React, view } = load()
const block = result({ isError: true, meta: {}, content: [{ type: 'text', text: 'Error: stale tag\nmore' }] })
React.reset()
const out = render(view({ t, toolName: 'edit', phase: 'result', block, useDisclosure: () => ({ expanded: true, toggle() {} }) }))
assert.ok(out.text.includes('Error: stale tag'))
assert.ok(out.classes.includes('ompe-error-summary'))
assert.ok(out.classes.includes('ompe-io-card') && out.text.includes('Output'))
assert.ok(!out.text.some((s) => /^\+\d/.test(s)))
})
test('preparing and running phases render without a body', () => {
const { React, view } = load()
React.reset()
let out = render(view({ t, toolName: 'edit', phase: 'preparing', block: { phase: 'preparing', callId: 'c' }, useToolCallArgumentsPartial: () => 'x'.repeat(3000) }))
assert.ok(out.text.includes('Edit'))
React.reset()
out = render(view({ t, toolName: 'edit', phase: 'start', block: { phase: 'start', callId: 'c', argsRaw: JSON.stringify({ input: '[x/y.md#1A2B]\nPUT 1.=1:\n+a' }) } }))
assert.ok(out.text.includes('x/y.md'))
})