Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 15 additions & 42 deletions clis/chatwise/ask.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { selectorError } from '@jackwener/opencli/errors';
import { selectorError, TimeoutError } from '@jackwener/opencli/errors';
import {
buildChatwiseInjectTextJs,
buildChatwiseMessageCountJs,
buildChatwiseResponseAfterJs,
normalizeTimeout,
} from './utils.js';
export const askCommand = cli({
site: 'chatwise',
name: 'ask',
Expand All @@ -15,34 +21,11 @@ export const askCommand = cli({
columns: ['Role', 'Text'],
func: async (page, kwargs) => {
const text = kwargs.text;
const timeout = parseInt(kwargs.timeout, 10) || 30;
const timeout = normalizeTimeout(kwargs.timeout);
// Snapshot content length
const beforeLen = await page.evaluate(`
(function() {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
return msgs.length;
})()
`);
const beforeLen = await page.evaluate(buildChatwiseMessageCountJs());
// Send message
const injected = await page.evaluate(`
(function(text) {
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}
if (!composer) return false;
composer.focus();
if (composer.tagName === 'TEXTAREA') {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected)
throw selectorError('ChatWise input element');
await page.wait(0.5);
Expand All @@ -53,25 +36,15 @@ export const askCommand = cli({
let response = '';
for (let i = 0; i < maxPolls; i++) {
await page.wait(pollInterval);
const result = await page.evaluate(`
(function(prevLen) {
const msgs = document.querySelectorAll('[data-message-id], [class*="message"], [class*="bubble"]');
if (msgs.length <= prevLen) return null;
const last = msgs[msgs.length - 1];
const text = last.innerText || last.textContent;
return text ? text.trim() : null;
})(${beforeLen})
`);
const result = await page.evaluate(buildChatwiseResponseAfterJs(beforeLen, text));
if (result) {
response = result;
break;
const next = String(result).trim();
if (next === response) break;
response = next;
}
}
if (!response) {
return [
{ Role: 'User', Text: text },
{ Role: 'System', Text: `No response within ${timeout}s.` },
];
throw new TimeoutError('ChatWise response', timeout, 'Confirm ChatWise is done generating, then retry with a larger --timeout if needed.');
}
return [
{ Role: 'User', Text: text },
Expand Down
186 changes: 186 additions & 0 deletions clis/chatwise/composer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { ArgumentError, TimeoutError } from '@jackwener/opencli/errors';
import {
buildChatwiseInjectTextJs,
buildChatwiseMessageCountJs,
buildChatwiseResponseAfterJs,
normalizeTimeout,
scoreChatwiseComposerCandidate,
selectBestChatwiseComposer,
} from './utils.js';
import { askCommand } from './ask.js';

function candidate(overrides = {}) {
return {
index: 0,
hidden: false,
role: 'textbox',
classes: 'cm-content cm-lineWrapping',
editorClasses: 'cm-editor',
ariaLabel: '',
placeholder: '',
text: '',
rect: { y: 0, h: 30 },
...overrides,
};
}

function runBrowserScript(html, script) {
const dom = new JSDOM(html, { url: 'app://chatwise.local/', runScripts: 'outside-only' });
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 400 });
Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 32 });
dom.window.HTMLElement.prototype.getClientRects = () => [{ length: 1 }];
dom.window.document.execCommand = () => false;
return { dom, result: dom.window.eval(script) };
}

function makePage(evaluateResults = []) {
const evaluate = vi.fn();
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
evaluate.mockResolvedValue(null);
return {
evaluate,
wait: vi.fn().mockResolvedValue(undefined),
pressKey: vi.fn().mockResolvedValue(undefined),
};
}

describe('chatwise composer selection', () => {
it('prefers the main composer over auxiliary contenteditable editors', () => {
const mainComposer = candidate({
index: 0,
placeholder: 'placeholder Enter a message here, press ⏎ to send',
rect: { y: 860, h: 32 },
});
const optionalDescription = candidate({
index: 1,
placeholder: 'placeholder Optional description',
editorClasses: 'cm-editor simple-editor',
rect: { y: 400, h: 32 },
});
const userContext = candidate({
index: 2,
text: '# User Context Document',
editorClasses: 'cm-editor simple-editor',
rect: { y: 460, h: 1200 },
});

expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
scoreChatwiseComposerCandidate(optionalDescription, 900),
);
expect(scoreChatwiseComposerCandidate(mainComposer, 900)).toBeGreaterThan(
scoreChatwiseComposerCandidate(userContext, 900),
);

expect(selectBestChatwiseComposer([
optionalDescription,
userContext,
mainComposer,
], 900)?.index).toBe(0);
});

it('rejects hidden or low-confidence candidates instead of injecting into the wrong editor', () => {
expect(selectBestChatwiseComposer([
candidate({
index: 0,
hidden: true,
placeholder: 'Enter a message here, press ⏎ to send',
rect: { y: 860, h: 32 },
}),
], 900)).toBeNull();

expect(selectBestChatwiseComposer([
candidate({
index: 1,
placeholder: 'Optional description',
editorClasses: 'cm-editor simple-editor',
rect: { y: 860, h: 32 },
}),
candidate({
index: 2,
text: '# User Context Document',
editorClasses: 'cm-editor simple-editor',
rect: { y: 870, h: 32 },
}),
], 900)).toBeNull();
});

it('injects text into the scored main composer instead of the last contenteditable', () => {
const html = `
<div class="cm-editor simple-editor">
<div class="cm-placeholder">Optional description</div>
<div id="optional" contenteditable="true" role="textbox"></div>
</div>
<div class="cm-editor">
<div class="cm-placeholder">Enter a message here, press ⏎ to send</div>
<div id="main" class="cm-content" contenteditable="true" role="textbox"></div>
</div>
<div class="cm-editor simple-editor">
<div id="context" contenteditable="true" role="textbox"># User Context Document</div>
</div>
`;

const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));

expect(result).toBe(true);
expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello');
expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
});

it('fails injection when only auxiliary editors are present', () => {
const html = `
<div class="cm-editor simple-editor">
<div class="cm-placeholder">Optional description</div>
<div id="optional" contenteditable="true" role="textbox"></div>
</div>
<div class="cm-editor simple-editor">
<div id="context" contenteditable="true" role="textbox"># User Context Document</div>
</div>
`;

const { dom, result } = runBrowserScript(html, buildChatwiseInjectTextJs('hello'));

expect(result).toBe(false);
expect(dom.window.document.querySelector('#optional')?.textContent).toBe('');
expect(dom.window.document.querySelector('#context')?.textContent).toBe('# User Context Document');
});

it('reads only real message wrapper content after the previous count', () => {
const html = `
<div class="group/message">old message</div>
<div class="timestamp">12:00</div>
<div class="group/message">new assistant answer</div>
`;

expect(runBrowserScript(html, buildChatwiseMessageCountJs()).result).toBe(2);
expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBe('new assistant answer');
});

it('does not treat the user prompt wrapper as an assistant response', () => {
const html = `
<div class="group/message">old message</div>
<div class="group/message">user prompt</div>
`;

expect(runBrowserScript(html, buildChatwiseResponseAfterJs(1, 'user prompt')).result).toBeNull();
});

it('validates timeout explicitly', () => {
expect(normalizeTimeout(undefined)).toBe(30);
expect(() => normalizeTimeout('0')).toThrow(ArgumentError);
expect(() => normalizeTimeout('301')).toThrow(ArgumentError);
});

it('fails fast when ask times out instead of returning a System success row', async () => {
const page = makePage([
1,
true,
null,
]);

await expect(askCommand.func(page, { text: 'hello', timeout: 1 }))
.rejects.toBeInstanceOf(TimeoutError);
});
});
26 changes: 2 additions & 24 deletions clis/chatwise/send.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { selectorError } from '@jackwener/opencli/errors';
import { buildChatwiseInjectTextJs } from './utils.js';
export const sendCommand = cli({
site: 'chatwise',
name: 'send',
Expand All @@ -12,30 +13,7 @@ export const sendCommand = cli({
columns: ['Status', 'InjectedText'],
func: async (page, kwargs) => {
const text = kwargs.text;
const injected = await page.evaluate(`
(function(text) {
// ChatWise input can be textarea or contenteditable
let composer = document.querySelector('textarea');
if (!composer) {
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
composer = editables.length > 0 ? editables[editables.length - 1] : null;
}

if (!composer) return false;

composer.focus();

if (composer.tagName === 'TEXTAREA') {
// For textarea, set value and dispatch input event
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
nativeInputValueSetter.call(composer, text);
composer.dispatchEvent(new Event('input', { bubbles: true }));
} else {
document.execCommand('insertText', false, text);
}
return true;
})(${JSON.stringify(text)})
`);
const injected = await page.evaluate(buildChatwiseInjectTextJs(text));
if (!injected)
throw selectorError('ChatWise input element');
await page.wait(0.5);
Expand Down
Loading
Loading