Files
sausage/userland/web/render.test.cjs
T

100 lines
7.7 KiB
JavaScript

// Test the extracted page script without a server or a browser dependency.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
class Element {
constructor(tag) { this.tag = tag; this.childNodes = []; this.innerHTML = ''; this.textContent = ''; this.dataset = {}; this.attributes = {}; }
setAttribute(name, value) { this.attributes[name] = value; }
append(...nodes) { this.childNodes.push(...nodes); }
replaceChildren(...nodes) { this.childNodes = nodes; }
requestSubmit() { this.submits = (this.submits || 0) + 1; }
}
const elements = new Map();
const document = {
getElementById(id) { if (!elements.has(id)) elements.set(id, new Element(id)); return elements.get(id); },
createElement(tag) { return new Element(tag); },
};
const context = vm.createContext({ document, fetch: () => new Promise(() => {}), setTimeout() {} });
vm.runInContext(fs.readFileSync(process.argv[2], 'utf8'), context);
const run = source => vm.runInContext(source, context);
const render = value => { context.markdownInput = value; return run('markdown(markdownInput)'); };
const html = render('# Heading\n\n- **Bold** and *italic* [link](https://example.com) `inline`\n\n```js\nconst x = 1 < 2;\n```\n\n| Name | Value |\n| --- | --- |\n| one | two |\n\n<script>alert(1)</script>');
for (const expected of ['<h1>', '<ul>', '<strong>', '<em>', '<a href="https://example.com">', '<code>inline</code>', '<pre><code', '<table>', '<td>two</td>', '&lt;script&gt;']) assert.ok(html.includes(expected), expected);
assert.ok(!html.includes('<script>'));
assert.ok(run("markdown('Text <b>raw</b> & more')").includes('&lt;b&gt;raw&lt;/b&gt;'));
const entry = record => { context.fixture = record; run('entry(fixture)'); };
entry({ kind: 'pi.user', model: { content: '# User heading' } });
assert.ok(elements.get('entries').childNodes[0].childNodes[1].innerHTML.includes('<h1>User heading</h1>'));
entry({ kind: 'pi.assistant', model: { content: [
{ type: 'thinking', thinking: '', signature: 'empty' },
{ type: 'thinking', thinking: 'Check the files.' },
{ type: 'toolCall', id: 'one', name: 'bash', arguments: { command: 'ls <missing>' } },
{ type: 'toolCall', id: 'two', name: 'bash', arguments: { command: 'pwd' } },
{ type: 'text', text: '**Answer**' },
] } });
assert.equal(run('tools.size'), 2);
assert.equal(run("tools.get('one').card.innerHTML"), '<strong>bash</strong><pre><code>ls &lt;missing&gt;</code></pre>');
entry({ kind: 'pi.tool-result', model: [{ toolCallId: 'two', toolName: 'bash', content: [{ type: 'text', text: '/work' }] }] });
entry({ kind: 'pi.tool-result', model: [{ toolCallId: 'one', toolName: 'bash', isError: true, content: [{ type: 'text', text: '<failure>\n'.repeat(12) }] }] });
assert.equal(run('tools.size'), 2, 'Results reuse cards by ID, not by name');
assert.equal(run("tools.get('one').card.className"), 'tool error');
assert.ok(run("tools.get('one').result.innerHTML").includes('<details><summary>Error</summary>'));
assert.ok(run("tools.get('one').result.innerHTML").includes('&lt;failure&gt;'));
assert.ok(run("tools.get('two').result.innerHTML").includes('<details open><summary>Result</summary><pre>/work</pre>'));
const assistant = elements.get('entries').childNodes[1];
assert.equal(assistant.childNodes.filter(node => node.tag === 'em').length, 1);
assert.equal(assistant.childNodes[0].className, 'thinking');
assert.equal(assistant.childNodes[0].textContent, 'Check the files.');
assert.ok(assistant.childNodes.at(-1).innerHTML.includes('<strong>Answer</strong>'));
for (const name of ['read', 'write']) assert.equal(run(`toolArguments({name:'${name}',arguments:{path:'/a<b'}})`), '<code>/a&lt;b</code>');
const edit = run("toolArguments({name:'edit',arguments:{path:'/file',edits:[{oldText:'<old>',newText:'<new>'}]}})");
assert.ok(edit.includes('Old text</div><pre>&lt;old&gt;</pre>'));
assert.ok(edit.includes('New text</div><pre>&lt;new&gt;</pre>'));
const keydown = elements.get('input').onkeydown;
for (const modifier of ['ctrlKey', 'metaKey']) {
let prevented = false;
keydown({ key: 'Enter', [modifier]: true, preventDefault() { prevented = true; } });
assert.ok(prevented);
}
assert.equal(elements.get('compose').submits, 2);
keydown({ key: 'Enter', preventDefault() { assert.fail('Plain Enter must insert a newline'); } });
keydown({ key: 'Enter', ctrlKey: true, isComposing: true, preventDefault() { assert.fail('Do not submit IME composition'); } });
assert.equal(elements.get('compose').submits, 2);
run('select(2)');
assert.equal(run('tools.size'), 0);
assert.equal(elements.get('entries').childNodes.length, 0);
assert.equal(run("requestSummary('Give access to 1 credential items for 1 hour.')"),'Give access to 1 credential item for 1 hour.');
assert.equal(run("requestSummary('Give access to 11 credential items for 1 hour.')"),'Give access to 11 credential items for 1 hour.');
context.conversations=[{id:1,agent:null},{id:24,agent:null},{id:25,agent:{}},{id:14,agent:{model:{provider:'faux'}}}];
assert.equal(run('visibleConversations(conversations).map(c=>c.id).join()'),'1,14');
vm.runInContext(fs.readFileSync(__dirname+'/credentials-view.js','utf8').split('const credentialView=')[0],context);
const config={id:'one',name:'Example <item>',rules:[{scheme:'https',hosts:['api.example.com'],port:443,path_prefixes:['/v1'],methods:['GET']}],fields:[{name:'token',public:false,field_type:'text'}],transform_js:'present'};
context.review={summary:'Create item',subject:{kind:'program',id:'/bin/curl'},payload:{create:config}};
context.items=[config];
let review=run('credentialReview(review,items)');
for(const value of ['Example &lt;item&gt;','https','api.example.com','443','/v1','GET','token — Secret, text','Transformation script: Present','Program /bin/curl'])assert.ok(review.includes(value),value);
assert.ok(review.includes('<details><summary>Technical details</summary><pre>'));
assert.ok(!review.split('<details>')[0].includes('"payload"'));
context.review={summary:'Grant access',subject:{kind:'conversation',id:'14'},payload:{items:['one'],duration_secs:3600}};
review=run('credentialReview(review,items)');
assert.ok(review.includes('Conversation #14'));
assert.ok(review.includes('Grant access to:'));
context.review={summary:'Empty rules',subject:{kind:'program',id:'<caller>'},payload:{create:{...config,rules:[],fields:[],transform_js:''}}};
review=run('credentialReview(review,items)');
assert.ok(review.includes('<h4>Access rules</h4><p>None.</p>'));
assert.ok(review.includes('Transformation script: None.'));
assert.ok(review.includes('&lt;caller&gt;'));
const link=new Element('a');link.hash='#approve/request';link.remove=()=>{link.removed=true;};document.getElementById('approvals').append(link);
run("dismissCredentialRequest('request')");assert.ok(link.removed);assert.ok(run("decidedRequests.has('request')"));
context.fetch=async url=>({ok:true,json:async()=>url.endsWith('/conversations')?{conversations:context.conversations,main_agent:{conversationId:14}}:url.includes('/entries')?{entries:[]}:{requests:[]}});
(async()=>{
run('selected=null');await run('refresh()');
const buttons=elements.get('conversations').childNodes;
assert.equal(buttons.length,2,'Event-only conversations do not render buttons');
assert.equal(buttons[0].textContent,'World log');
assert.equal(buttons[1].textContent,'Conversation #14');
buttons[0].onclick();assert.equal(buttons[0].attributes['aria-current'],'true');assert.equal(buttons[1].attributes['aria-current'],'false');assert.ok(elements.get('compose').hidden,'A conversation without a model is read-only');
assert.equal(elements.get('entries').dataset.empty,'This conversation has no messages.');
console.log('PASS chat rendering, approval review, immediate decisions, and conversation filter');
})().catch(error=>{console.error(error);process.exitCode=1;});