100 lines
7.7 KiB
JavaScript
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>', '<script>']) assert.ok(html.includes(expected), expected);
|
|
assert.ok(!html.includes('<script>'));
|
|
assert.ok(run("markdown('Text <b>raw</b> & more')").includes('<b>raw</b>'));
|
|
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 <missing></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('<failure>'));
|
|
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<b</code>');
|
|
const edit = run("toolArguments({name:'edit',arguments:{path:'/file',edits:[{oldText:'<old>',newText:'<new>'}]}})");
|
|
assert.ok(edit.includes('Old text</div><pre><old></pre>'));
|
|
assert.ok(edit.includes('New text</div><pre><new></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 <item>','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('<caller>'));
|
|
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;});
|