9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
192 lines
8.2 KiB
JavaScript
192 lines
8.2 KiB
JavaScript
// Shared helpers for the Qoder adapter.
|
|
//
|
|
// Qoder is an Electron-based AI IDE (Alibaba; com.qoder.ide). It's
|
|
// VSCode-derived (same Electron + Monaco shell) and shares many DOM
|
|
// patterns with Trae SOLO. CDP port: 9237 (declared in
|
|
// src/electron-apps.ts and launched by ~/.claude/bin/qoder-launch-with-cdp.sh).
|
|
//
|
|
// Terminology:
|
|
// - "Quest" = conversation (Qoder's term for a chat thread)
|
|
// - "Workspace" = open folder (VSCode-style)
|
|
// - "Knowledge" = personal/team knowledge base
|
|
// - "Marketplace"= plugin/skill marketplace
|
|
|
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
|
export const IS_VISIBLE_JS = `
|
|
const isVisible = (el) => {
|
|
if (!el) return false;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 1 || r.height < 1) return false;
|
|
const cs = getComputedStyle(el);
|
|
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') return false;
|
|
return true;
|
|
};
|
|
`;
|
|
|
|
export function unwrapEvaluateResult(payload) {
|
|
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
|
|
return payload.data;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export async function evaluateQoder(page, script) {
|
|
return unwrapEvaluateResult(await page.evaluate(script));
|
|
}
|
|
|
|
export function requireArrayResult(value, label) {
|
|
if (!Array.isArray(value)) {
|
|
throw new CommandExecutionError(`${label}: unexpected evaluate result shape`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parsePositiveInt(raw, fallback, label) {
|
|
const value = raw == null || raw === '' ? fallback : Number(raw);
|
|
if (!Number.isInteger(value) || value < 1) {
|
|
throw new ArgumentError(`${label} must be a positive integer`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// Build a JS snippet that clicks the first visible element matching any
|
|
// of the given CSS selectors. Uses the full pointer-event chain to
|
|
// satisfy radix/headless menu libraries.
|
|
export function clickFirstScript(selectors) {
|
|
return `(() => {
|
|
${IS_VISIBLE_JS}
|
|
const sels = ${JSON.stringify(selectors)};
|
|
for (const sel of sels) {
|
|
const target = Array.from(document.querySelectorAll(sel)).filter(isVisible)[0];
|
|
if (target) {
|
|
const r = target.getBoundingClientRect();
|
|
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
|
target.dispatchEvent(new PointerEvent('pointerdown', opts));
|
|
target.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
target.dispatchEvent(new PointerEvent('pointerup', opts));
|
|
target.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
target.click();
|
|
return { ok: true, sel };
|
|
}
|
|
}
|
|
return { ok: false, reason: 'No matching visible element.' };
|
|
})()`;
|
|
}
|
|
|
|
// Variant: match a button by visible innerText (substring or full match).
|
|
// Useful when Qoder buttons lack aria-label.
|
|
export function clickByTextScript(textPatterns, opts = {}) {
|
|
const { exact = false, maxLen = 60 } = opts;
|
|
return `(() => {
|
|
${IS_VISIBLE_JS}
|
|
const patterns = ${JSON.stringify(textPatterns)};
|
|
const exact = ${exact ? 'true' : 'false'};
|
|
const maxLen = ${maxLen};
|
|
const isVis = isVisible;
|
|
const candidates = Array.from(document.querySelectorAll('button, [role="button"], a, [role="tab"]')).filter(isVis);
|
|
for (const pat of patterns) {
|
|
const target = candidates.find((b) => {
|
|
const tx = (b.innerText || b.textContent || '').trim();
|
|
if (tx.length > maxLen) return false;
|
|
return exact ? tx === pat : tx.toLowerCase().includes(pat.toLowerCase());
|
|
});
|
|
if (target) {
|
|
const r = target.getBoundingClientRect();
|
|
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
|
target.dispatchEvent(new PointerEvent('pointerdown', opts));
|
|
target.dispatchEvent(new MouseEvent('mousedown', opts));
|
|
target.dispatchEvent(new PointerEvent('pointerup', opts));
|
|
target.dispatchEvent(new MouseEvent('mouseup', opts));
|
|
target.click();
|
|
return { ok: true, matched: pat };
|
|
}
|
|
}
|
|
return { ok: false, reason: 'No button matching: ' + patterns.join(' / ') };
|
|
})()`;
|
|
}
|
|
|
|
export const QODER_TURNS_JS = `(() => {
|
|
${IS_VISIBLE_JS}
|
|
const chatPanes = Array.from(document.querySelectorAll('[class*="chat"i], [class*="conversation"i], [class*="message"i]')).filter(isVisible);
|
|
if (!chatPanes.length) return [];
|
|
const pane = chatPanes.sort((a, b) => b.getBoundingClientRect().height - a.getBoundingClientRect().height)[0];
|
|
const candidates = Array.from(pane.querySelectorAll('div, article, [class*="message"i], [class*="turn"i], [class*="bubble"i]'))
|
|
.filter(isVisible)
|
|
.filter((el) => {
|
|
const tx = (el.innerText || '').trim();
|
|
return tx.length > 5 && tx.length < 4000 && el.children.length < 20;
|
|
});
|
|
const seen = new Set();
|
|
return candidates.map((el) => {
|
|
const tx = (el.innerText || '').trim().replace(/\\s+/g, ' ');
|
|
if (seen.has(tx)) return null;
|
|
seen.add(tx);
|
|
const cls = (el.className || '').toString().toLowerCase();
|
|
const role = /user|me-|right/.test(cls) ? 'User' : (/assistant|ai|bot|response/.test(cls) ? 'Assistant' : 'Turn');
|
|
return { role, text: tx };
|
|
}).filter(Boolean);
|
|
})()`;
|
|
|
|
export function buildQoderInjectTextScript(text) {
|
|
return `(() => {
|
|
${IS_VISIBLE_JS}
|
|
const editors = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter(isVisible);
|
|
const candidates = editors.map((editor, index) => {
|
|
const rect = editor.getBoundingClientRect();
|
|
const attrs = [
|
|
editor.getAttribute('aria-label') || '',
|
|
editor.getAttribute('placeholder') || '',
|
|
editor.getAttribute('data-placeholder') || '',
|
|
editor.textContent || '',
|
|
editor.className || '',
|
|
editor.closest('[class*="composer"i], [class*="input"i], [class*="chat"i]')?.className || '',
|
|
].join(' ').toLowerCase();
|
|
let score = 0;
|
|
if (editor.getAttribute('role') === 'textbox') score += 25;
|
|
if (attrs.includes('message') || attrs.includes('prompt') || attrs.includes('ask')) score += 120;
|
|
if (attrs.includes('composer') || attrs.includes('input')) score += 60;
|
|
if (attrs.includes('optional description') || attrs.includes('user context document') || attrs.includes('knowledge')) score -= 240;
|
|
if (window.innerHeight > 0) score += Math.max(0, 80 - Math.abs(window.innerHeight - (rect.y + rect.height)) / 8);
|
|
return { editor, index, score };
|
|
}).sort((a, b) => b.score - a.score || b.index - a.index);
|
|
const best = candidates[0];
|
|
if (!best || best.score < 40) return { ok: false, reason: 'No high-confidence Qoder composer found.' };
|
|
const editor = best.editor;
|
|
editor.focus();
|
|
document.execCommand('selectAll', false);
|
|
const inserted = document.execCommand('insertText', false, ${JSON.stringify(String(text ?? ''))});
|
|
if (!inserted) {
|
|
editor.textContent = ${JSON.stringify(String(text ?? ''))};
|
|
}
|
|
editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(String(text ?? ''))} }));
|
|
return { ok: true, score: best.score };
|
|
})()`;
|
|
}
|
|
|
|
export const QODER_MESSAGE_COUNT_JS = `(() => {
|
|
const turns = ${QODER_TURNS_JS};
|
|
return Array.isArray(turns) ? turns.length : 0;
|
|
})()`;
|
|
|
|
export function qoderResponseAfterScript(previousCount, userText) {
|
|
return `(() => {
|
|
const turns = ${QODER_TURNS_JS};
|
|
if (!Array.isArray(turns) || turns.length <= ${Number(previousCount) || 0}) return null;
|
|
const fresh = turns.slice(${Number(previousCount) || 0})
|
|
.filter((turn) => turn && turn.text && turn.text !== ${JSON.stringify(String(userText ?? ''))});
|
|
return fresh.length ? fresh[fresh.length - 1] : null;
|
|
})()`;
|
|
}
|
|
|
|
// Wait for an element to appear by polling page.evaluate.
|
|
export async function waitForSelector(page, selector, timeoutMs = 5000, intervalMs = 200) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const exists = await page.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`);
|
|
if (exists) return true;
|
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
}
|
|
return false;
|
|
}
|