109 lines
5.9 KiB
JavaScript
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) => f(), register: (_m, C) => { 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'))
|
|
})
|