chore: import upstream snapshot with attribution
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Composer-area commands for Qoder.
|
||||
//
|
||||
// prompt-enhance — click "Prompt Enhance" (rewrites the current draft)
|
||||
// open-editor — click "Open Editor" (open the chat draft in a full editor pane)
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { clickByTextScript, evaluateQoder } from './_utils.js';
|
||||
|
||||
// -------- prompt-enhance --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'prompt-enhance',
|
||||
access: 'write',
|
||||
description: 'Click "Prompt Enhance" — Qoder rewrites the current composer draft for better LLM consumption.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Prompt Enhance']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Prompt Enhance button not found', '');
|
||||
await page.wait(0.5);
|
||||
return [{ Status: 'enhanced (check composer)' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- open-editor --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'open-editor',
|
||||
access: 'write',
|
||||
description: 'Click "Open Editor" — opens the current draft in a full editor pane.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Open Editor']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Open Editor button not found', '');
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { evaluateQoder, IS_VISIBLE_JS, parsePositiveInt, requireArrayResult } from './_utils.js';
|
||||
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: 'List Quests visible in the Qoder sidebar. Returns title + visible metadata.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 50 },
|
||||
],
|
||||
columns: ['Index', 'Title'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = parsePositiveInt(kwargs?.limit, 50, '--limit');
|
||||
const items = requireArrayResult(await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// Qoder renders quests in the sidebar Quest List. They appear as
|
||||
// clickable rows; structure is iterative-discovery — find rows
|
||||
// that have either role=button or a click handler and contain a title.
|
||||
// We use the heuristic: any element under the sidebar (left panel)
|
||||
// whose textContent looks like a Quest title (< 100 chars, no menu indicator).
|
||||
const sidebars = Array.from(document.querySelectorAll('[class*="sidebar"i], [class*="quest-list"i], [class*="quest"i]')).filter(isVisible);
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
sidebars.forEach((sb) => {
|
||||
const rows = Array.from(sb.querySelectorAll('[role="button"], button, [class*="item"i]')).filter(isVisible);
|
||||
rows.forEach((r) => {
|
||||
const txt = (r.innerText || r.textContent || '').trim().replace(/\\s+/g, ' ');
|
||||
if (!txt || txt.length < 2 || txt.length > 200) return;
|
||||
// Skip menu items, headers, action buttons.
|
||||
if (/^(New Quest|Search|Settings|View all|Knowledge|Marketplace|Credits Usage|Pin|Add Workspace|Open Editor|More Actions|Open Panel|Collapse|leo|button)$/i.test(txt.trim())) return;
|
||||
if (txt.includes('⌘')) return;
|
||||
if (seen.has(txt)) return;
|
||||
seen.add(txt);
|
||||
out.push(txt);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
})()`), 'qoder history');
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('qoder history', 'No quests visible. Try widening the sidebar or selecting a workspace.');
|
||||
}
|
||||
return items.slice(0, limit).map((t, i) => ({ Index: i + 1, Title: t }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import { getRegistry } from '@jackwener/opencli/registry';
|
||||
import './quest.js';
|
||||
import './history.js';
|
||||
import './read.js';
|
||||
import './status.js';
|
||||
import './ui.js';
|
||||
import './composer.js';
|
||||
import {
|
||||
buildQoderInjectTextScript,
|
||||
evaluateQoder,
|
||||
parsePositiveInt,
|
||||
unwrapEvaluateResult,
|
||||
} from './_utils.js';
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function runBrowserScript(html, script) {
|
||||
const dom = new JSDOM(html, { url: 'vscode-file://qoder/agents-window.html', runScripts: 'outside-only' });
|
||||
dom.window.InputEvent = dom.window.Event;
|
||||
dom.window.document.execCommand = vi.fn((command, _showUi, value) => {
|
||||
const active = dom.window.document.activeElement;
|
||||
if (command === 'insertText' && active) active.textContent = value;
|
||||
return true;
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value() {
|
||||
const y = this.id === 'main' ? 860 : 320;
|
||||
return { x: 0, y, width: 420, height: 32 };
|
||||
},
|
||||
});
|
||||
return { dom, result: dom.window.eval(script) };
|
||||
}
|
||||
|
||||
describe('qoder registry', () => {
|
||||
it('registers the expected command surface with access metadata', () => {
|
||||
const registry = getRegistry();
|
||||
const expected = new Map([
|
||||
['qoder/status', 'read'],
|
||||
['qoder/history', 'read'],
|
||||
['qoder/read', 'read'],
|
||||
['qoder/search', 'read'],
|
||||
['qoder/account', 'read'],
|
||||
['qoder/credits', 'read'],
|
||||
['qoder/more-actions', 'read'],
|
||||
['qoder/new', 'write'],
|
||||
['qoder/send', 'write'],
|
||||
['qoder/ask', 'write'],
|
||||
['qoder/sidebar-toggle', 'write'],
|
||||
['qoder/open-panel', 'write'],
|
||||
['qoder/settings', 'write'],
|
||||
['qoder/knowledge', 'write'],
|
||||
['qoder/marketplace', 'write'],
|
||||
['qoder/view-all', 'write'],
|
||||
['qoder/add-workspace', 'write'],
|
||||
['qoder/prompt-enhance', 'write'],
|
||||
['qoder/open-editor', 'write'],
|
||||
]);
|
||||
for (const [name, access] of expected) {
|
||||
expect(registry.get(name)).toMatchObject({ access });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('qoder helpers', () => {
|
||||
it('unwraps Browser Bridge evaluate envelopes', async () => {
|
||||
expect(unwrapEvaluateResult({ session: { id: 's1' }, data: ['row'] })).toEqual(['row']);
|
||||
const page = makePage([{ session: { id: 's1' }, data: { ok: true } }]);
|
||||
await expect(evaluateQoder(page, 'script')).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('validates positive integer options explicitly', () => {
|
||||
expect(parsePositiveInt(undefined, 20, '--limit')).toBe(20);
|
||||
expect(parsePositiveInt('3', 20, '--limit')).toBe(3);
|
||||
expect(() => parsePositiveInt(0, 20, '--limit')).toThrow(ArgumentError);
|
||||
expect(() => parsePositiveInt('abc', 20, '--limit')).toThrow(ArgumentError);
|
||||
});
|
||||
|
||||
it('injects into the high-confidence composer instead of arbitrary contenteditable panes', () => {
|
||||
const html = `
|
||||
<div class="cm-editor simple-editor">
|
||||
<div id="optional" contenteditable="true" role="textbox">Optional description</div>
|
||||
</div>
|
||||
<div class="qoder-composer input">
|
||||
<div id="main" contenteditable="true" role="textbox" aria-label="Message Qoder"></div>
|
||||
</div>
|
||||
`;
|
||||
const { dom, result } = runBrowserScript(html, buildQoderInjectTextScript('hello qoder'));
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(dom.window.document.querySelector('#main')?.textContent).toBe('hello qoder');
|
||||
expect(dom.window.document.querySelector('#optional')?.textContent).toBe('Optional description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('qoder send command', () => {
|
||||
const send = getRegistry().get('qoder/send');
|
||||
|
||||
it('requires post-submit evidence before returning success', async () => {
|
||||
const page = makePage([1, { ok: true }, { ok: true }, 2]);
|
||||
await expect(send.func(page, { text: 'hello' })).resolves.toEqual([
|
||||
{ Status: 'sent', Length: '5' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('typed-fails when no composer can be selected', async () => {
|
||||
const page = makePage([1, { ok: false, reason: 'No high-confidence Qoder composer found.' }]);
|
||||
await expect(send.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('typed-fails when clicking send does not create a visible message row', async () => {
|
||||
const page = makePage([1, { ok: true }, { ok: true }, 1]);
|
||||
await expect(send.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// Quest (= conversation) lifecycle commands: new / send / ask.
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
TimeoutError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import {
|
||||
buildQoderInjectTextScript,
|
||||
clickByTextScript,
|
||||
clickFirstScript,
|
||||
evaluateQoder,
|
||||
parsePositiveInt,
|
||||
QODER_MESSAGE_COUNT_JS,
|
||||
qoderResponseAfterScript,
|
||||
} from './_utils.js';
|
||||
|
||||
async function waitForMessageCountGrowth(page, beforeCount, timeoutMs = 5000) {
|
||||
const attempts = Math.max(1, Math.ceil(timeoutMs / 500));
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await page.wait(0.5);
|
||||
const afterCount = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS);
|
||||
if (Number(afterCount) > Number(beforeCount)) return afterCount;
|
||||
}
|
||||
return beforeCount;
|
||||
}
|
||||
|
||||
// -------- new --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'new',
|
||||
access: 'write',
|
||||
description: 'Start a new Qoder Quest (conversation). Clicks the "New Quest" button in the sidebar (or its ⌘N variant).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['New Quest']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'New Quest button not found', '');
|
||||
await page.wait(0.5);
|
||||
return [{ Status: 'started' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- send --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'send',
|
||||
access: 'write',
|
||||
description: 'Type text into the Qoder composer and click "Send message" (fire-and-forget).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'text', positional: true, required: true, help: 'Text to send' },
|
||||
],
|
||||
columns: ['Status', 'Length'],
|
||||
func: async (page, kwargs) => {
|
||||
const text = String(kwargs?.text || '').trim();
|
||||
if (!text) throw new ArgumentError('text is required');
|
||||
|
||||
const beforeCount = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS);
|
||||
const typeRes = await evaluateQoder(page, buildQoderInjectTextScript(text));
|
||||
if (!typeRes?.ok) throw new CommandExecutionError(typeRes?.reason || 'composer type failed', '');
|
||||
await page.wait(0.3);
|
||||
|
||||
// Click Send message
|
||||
const sendRes = await evaluateQoder(page, clickFirstScript([
|
||||
'button[aria-label="Send message"]',
|
||||
'button[title="Send message"]',
|
||||
]));
|
||||
if (!sendRes?.ok) {
|
||||
// Fallback: try clickByText.
|
||||
const textRes = await evaluateQoder(page, clickByTextScript(['Send message', 'Send', '发送']));
|
||||
if (!textRes?.ok) throw new CommandExecutionError('Send button not found', '');
|
||||
}
|
||||
const afterCount = await waitForMessageCountGrowth(page, beforeCount);
|
||||
if (Number(afterCount) <= Number(beforeCount)) {
|
||||
throw new CommandExecutionError('Qoder send did not create a new visible message row');
|
||||
}
|
||||
return [{ Status: 'sent', Length: String(text.length) }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- ask --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'ask',
|
||||
access: 'write',
|
||||
description: 'Send a prompt to Qoder and wait up to --timeout seconds for the reply (best-effort: polls for the chat turn count to grow + stabilize).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'text', positional: true, required: true, help: 'Prompt text' },
|
||||
{ name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds to wait' },
|
||||
],
|
||||
columns: ['Role', 'Text', 'WaitedSeconds'],
|
||||
func: async (page, kwargs) => {
|
||||
const text = String(kwargs?.text || '').trim();
|
||||
if (!text) throw new ArgumentError('text is required');
|
||||
const timeoutSec = parsePositiveInt(kwargs?.timeout, 120, '--timeout');
|
||||
|
||||
// Type + send (mirror `send` logic)
|
||||
const sendBefore = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS);
|
||||
const typeRes = await evaluateQoder(page, buildQoderInjectTextScript(text));
|
||||
if (!typeRes?.ok) throw new CommandExecutionError(typeRes?.reason || 'composer type failed', '');
|
||||
await page.wait(0.3);
|
||||
const sendRes = await evaluateQoder(page, clickFirstScript(['button[aria-label="Send message"]', 'button[title="Send message"]']));
|
||||
if (!sendRes?.ok) {
|
||||
const textRes = await evaluateQoder(page, clickByTextScript(['Send message', 'Send', '发送']));
|
||||
if (!textRes?.ok) throw new CommandExecutionError('Send button not found', '');
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + timeoutSec * 1000;
|
||||
let lastCount = sendBefore;
|
||||
let stableTicks = 0;
|
||||
let response = null;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const cur = await evaluateQoder(page, QODER_MESSAGE_COUNT_JS);
|
||||
if (cur !== lastCount) {
|
||||
lastCount = cur;
|
||||
stableTicks = 0;
|
||||
} else {
|
||||
stableTicks++;
|
||||
}
|
||||
// Consider stable after 6 idle ticks (≈9s no change) IF count grew at all.
|
||||
if (lastCount > sendBefore && stableTicks >= 6) {
|
||||
response = await evaluateQoder(page, qoderResponseAfterScript(sendBefore, text));
|
||||
if (response?.text) break;
|
||||
}
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (!response?.text) {
|
||||
throw new TimeoutError('Qoder response', timeoutSec, 'Confirm Qoder sent the prompt and finished generating, then retry with a larger --timeout.');
|
||||
}
|
||||
return [
|
||||
{ Role: 'User', Text: text, WaitedSeconds: String(elapsed) },
|
||||
{ Role: response.role || 'Assistant', Text: String(response.text).slice(0, 1200), WaitedSeconds: String(elapsed) },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { evaluateQoder, parsePositiveInt, QODER_TURNS_JS, requireArrayResult } from './_utils.js';
|
||||
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read messages in the current Qoder Quest. Returns role + text for each visible turn.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', required: false, default: 30 },
|
||||
],
|
||||
columns: ['Index', 'Role', 'Text'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = parsePositiveInt(kwargs?.limit, 30, '--limit');
|
||||
const turns = requireArrayResult(await evaluateQoder(page, QODER_TURNS_JS), 'qoder read');
|
||||
if (!turns.length) {
|
||||
throw new EmptyResultError('qoder read', 'No chat turns detected. Open a quest first.');
|
||||
}
|
||||
return turns.slice(0, limit).map((t, i) => ({
|
||||
Index: i + 1,
|
||||
Role: t.role,
|
||||
Text: (t.text || '').slice(0, 1200),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { evaluateQoder } from './_utils.js';
|
||||
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'status',
|
||||
access: 'read',
|
||||
description: 'Check Qoder CDP connection and report the current renderer URL + title.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status', 'Url', 'Title'],
|
||||
func: async (page) => {
|
||||
return [{
|
||||
Status: 'Connected',
|
||||
Url: await evaluateQoder(page, 'window.location.href'),
|
||||
Title: await evaluateQoder(page, 'document.title'),
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
// Global UI commands for Qoder.
|
||||
//
|
||||
// sidebar-toggle — Collapse Quest List (⌘B)
|
||||
// open-panel — Open Panel (⌥⌘B) — bottom panel
|
||||
// search — Search (⌘P) palette
|
||||
// settings — Settings
|
||||
// knowledge — open Knowledge view
|
||||
// marketplace — open Marketplace
|
||||
// credits — Credits Usage
|
||||
// view-all — View all quests
|
||||
// add-workspace — Add Workspace
|
||||
// account — read user info (the "leo"-style button at bottom)
|
||||
// more-actions — open More Actions menu + list items
|
||||
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import {
|
||||
ArgumentError,
|
||||
CommandExecutionError,
|
||||
EmptyResultError,
|
||||
} from '@jackwener/opencli/errors';
|
||||
import { IS_VISIBLE_JS, clickByTextScript, clickFirstScript, evaluateQoder, parsePositiveInt, requireArrayResult } from './_utils.js';
|
||||
|
||||
// -------- sidebar-toggle --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'sidebar-toggle',
|
||||
access: 'write',
|
||||
description: 'Collapse / Expand the Qoder Quest List sidebar (⌘B).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Collapse Quest List', 'Expand Quest List']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'sidebar-toggle failed', '');
|
||||
return [{ Status: `clicked: ${res.matched}` }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- open-panel --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'open-panel',
|
||||
access: 'write',
|
||||
description: 'Open / close the Qoder bottom panel (Output / Terminal / Debug Console). ⌥⌘B equivalent.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Open Panel', 'Close Panel']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'open-panel failed', '');
|
||||
return [{ Status: `clicked: ${res.matched}` }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- search --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'search',
|
||||
access: 'read',
|
||||
description: 'Open Qoder Search palette (⌘P), type a query, return matched options.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'query', positional: true, required: true, help: 'Search text' },
|
||||
{ name: 'limit', type: 'int', required: false, default: 20 },
|
||||
],
|
||||
columns: ['Index', 'Item'],
|
||||
func: async (page, kwargs) => {
|
||||
const query = String(kwargs?.query || '').trim();
|
||||
if (!query) throw new ArgumentError('query', 'is required');
|
||||
const limit = parsePositiveInt(kwargs?.limit, 20, '--limit');
|
||||
const openRes = await evaluateQoder(page, clickByTextScript(['Search']));
|
||||
if (!openRes?.ok) throw new CommandExecutionError(openRes?.reason || 'search open failed', '');
|
||||
await page.wait(0.5);
|
||||
// Type into the visible input (usually the most-recently mounted one).
|
||||
const fillRes = await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const inputs = Array.from(document.querySelectorAll('input[type="text"], input[type="search"], input:not([type])')).filter(isVisible);
|
||||
const input = inputs[inputs.length - 1];
|
||||
if (!input) return { ok: false, reason: 'No input visible.' };
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, ${JSON.stringify(query)});
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { ok: true };
|
||||
})()`);
|
||||
if (!fillRes?.ok) throw new CommandExecutionError(fillRes?.reason || 'search type failed', '');
|
||||
await page.wait(0.8);
|
||||
const items = requireArrayResult(await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// Search palette results: prefer [role=option], else any clickable row in the modal.
|
||||
const opts = Array.from(document.querySelectorAll('[role="option"], [role="menuitem"]')).filter(isVisible);
|
||||
const titles = opts.map((o) => {
|
||||
// Codex-style fix: text may be in per-char spans; use textContent.
|
||||
const titleEl = o.querySelector('.truncate, [class*="title"i]') || o;
|
||||
return (titleEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
|
||||
}).filter(Boolean);
|
||||
return [...new Set(titles)];
|
||||
})()`), 'qoder search');
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('qoder search', `No items matched "${query}".`);
|
||||
}
|
||||
return items.slice(0, limit).map((t, i) => ({ Index: i + 1, Item: t }));
|
||||
},
|
||||
});
|
||||
|
||||
// -------- settings --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'settings',
|
||||
access: 'write',
|
||||
description: 'Click the Settings button in the Qoder sidebar.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Settings']));
|
||||
if (!res?.ok) {
|
||||
// Fallback: try aria-label.
|
||||
const ariaRes = await evaluateQoder(page, clickFirstScript(['button[aria-label="Settings"]']));
|
||||
if (!ariaRes?.ok) throw new CommandExecutionError('Settings button not found', '');
|
||||
}
|
||||
await page.wait(0.5);
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- knowledge --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'knowledge',
|
||||
access: 'write',
|
||||
description: 'Open the Knowledge view (Qoder\'s personal/team knowledge base).',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Knowledge']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Knowledge button not found', '');
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- marketplace --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'marketplace',
|
||||
access: 'write',
|
||||
description: 'Open the Qoder Marketplace.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Marketplace']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Marketplace button not found', '');
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- credits --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'credits',
|
||||
access: 'read',
|
||||
description: 'Click "Credits Usage" and return the credits-usage display text.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Field', 'Value'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Credits Usage']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Credits Usage not visible', '');
|
||||
await page.wait(0.5);
|
||||
// Try to read whatever appears.
|
||||
const info = await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
// Find any dialog/popover that appeared.
|
||||
const popovers = Array.from(document.querySelectorAll('[role="dialog"], [class*="popover"i], [class*="popup"i]')).filter(isVisible);
|
||||
if (!popovers.length) return null;
|
||||
const pop = popovers[popovers.length - 1];
|
||||
return (pop.innerText || pop.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 600);
|
||||
})()`);
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
return [
|
||||
{ Field: 'Status', Value: 'clicked' },
|
||||
{ Field: 'Info', Value: info || '(no popover detected after click)' },
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- view-all --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'view-all',
|
||||
access: 'write',
|
||||
description: 'Click "View all" to show all Quests.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['View all']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'View all button not visible', '');
|
||||
return [{ Status: 'clicked' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- add-workspace --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'add-workspace',
|
||||
access: 'write',
|
||||
description: 'Click "Add Workspace" — opens the folder picker. Note: this opens a system file-picker dialog that Qoder controls; the actual folder selection must be done in the UI by the user.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Status'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['Add Workspace']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'Add Workspace button not found', '');
|
||||
return [{ Status: 'clicked — folder picker opened (manual selection required)' }];
|
||||
},
|
||||
});
|
||||
|
||||
// -------- account --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'account',
|
||||
access: 'read',
|
||||
description: 'Click the account button (username) in the Qoder sidebar and return the visible account dropdown items.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', required: false, help: 'Username text shown in the sidebar (default: tries common short labels)' },
|
||||
],
|
||||
columns: ['Field', 'Value'],
|
||||
func: async (page, kwargs) => {
|
||||
// The account button label is the user's display name. We try a few
|
||||
// common patterns: if --username is passed we use it, else we look
|
||||
// for any short button text that doesn't match other known controls.
|
||||
let label = String(kwargs?.username || '').trim();
|
||||
if (!label) {
|
||||
// Heuristic discovery.
|
||||
label = await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const btns = Array.from(document.querySelectorAll('button')).filter(isVisible);
|
||||
const known = new Set(['Pin', 'Copy', 'New Quest', 'Search', 'Settings', 'View all', 'Knowledge', 'Marketplace', 'Credits Usage', 'Open Editor', 'Add Workspace', 'More Actions', 'Send message', 'Prompt Enhance', 'Voice input', 'button']);
|
||||
const candidate = btns.find((b) => {
|
||||
const tx = (b.innerText || '').trim();
|
||||
if (!tx || tx.length > 30 || tx.includes('⌘') || known.has(tx)) return false;
|
||||
if (/^(New|Open|Add|Save|Edit|Cancel|OK|Close)/.test(tx)) return false;
|
||||
return true;
|
||||
});
|
||||
return candidate ? (candidate.innerText || '').trim() : '';
|
||||
})()`);
|
||||
}
|
||||
if (!label) {
|
||||
throw new CommandExecutionError('No account button detected. Pass --username <name> explicitly.', '');
|
||||
}
|
||||
// Click the username button
|
||||
const clickRes = await evaluateQoder(page, clickByTextScript([label], { exact: true, maxLen: 60 }));
|
||||
if (!clickRes?.ok) throw new CommandExecutionError(`Click on account button "${label}" failed`, '');
|
||||
await page.wait(0.5);
|
||||
const items = requireArrayResult(await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const popovers = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i], [class*="dropdown"i]')).filter(isVisible)
|
||||
.filter((el) => { const r = el.getBoundingClientRect(); return r.width < 500 && r.height < 600; });
|
||||
if (!popovers.length) return [];
|
||||
const pop = popovers[popovers.length - 1];
|
||||
return Array.from(pop.querySelectorAll('button, [role="menuitem"], a'))
|
||||
.filter(isVisible)
|
||||
.map((b) => (b.innerText || b.textContent || '').trim().replace(/\\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
})()`), 'qoder account');
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
const rows = [{ Field: 'Username', Value: label }];
|
||||
(items || []).slice(0, 20).forEach((item, i) => {
|
||||
rows.push({ Field: `Item[${i + 1}]`, Value: item });
|
||||
});
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
|
||||
// -------- more-actions --------
|
||||
cli({
|
||||
site: 'qoder',
|
||||
name: 'more-actions',
|
||||
access: 'read',
|
||||
description: 'Click the "More Actions" button and list its menu items.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [],
|
||||
columns: ['Index', 'Item'],
|
||||
func: async (page) => {
|
||||
const res = await evaluateQoder(page, clickByTextScript(['More Actions']));
|
||||
if (!res?.ok) throw new CommandExecutionError(res?.reason || 'More Actions button not found', '');
|
||||
await page.wait(0.4);
|
||||
const items = requireArrayResult(await evaluateQoder(page, `(() => {
|
||||
${IS_VISIBLE_JS}
|
||||
const popovers = Array.from(document.querySelectorAll('[role="menu"], [role="dialog"], [class*="popover"i], [class*="menu"i]')).filter(isVisible)
|
||||
.filter((el) => { const r = el.getBoundingClientRect(); return r.width < 500 && r.height < 600; });
|
||||
if (!popovers.length) return [];
|
||||
const pop = popovers[popovers.length - 1];
|
||||
return Array.from(pop.querySelectorAll('[role="menuitem"], button'))
|
||||
.filter(isVisible)
|
||||
.map((b) => (b.innerText || b.textContent || '').trim().replace(/\\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
})()`), 'qoder more-actions');
|
||||
try { await page.evaluate(`document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));`); } catch {}
|
||||
if (!items.length) {
|
||||
throw new EmptyResultError('qoder more-actions', 'Menu opened but no items detected.');
|
||||
}
|
||||
return items.map((it, i) => ({ Index: i + 1, Item: it }));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user