mirror of
https://github.com/logseq/logseq.git
synced 2026-05-01 01:16:27 +00:00
794 lines
25 KiB
JavaScript
Executable File
794 lines
25 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const {
|
|
buildOperationPlan,
|
|
parseArgs,
|
|
} = require('./lib/logseq-electron-op-sim.cjs');
|
|
|
|
const DEFAULT_TARGET_TITLE = 'Logseq';
|
|
const WebSocketCtor = globalThis.WebSocket;
|
|
const DEBUG_TARGET_WAIT_TIMEOUT_MS = 30000;
|
|
const DEBUG_TARGET_RETRY_DELAY_MS = 300;
|
|
const RENDERER_READY_TIMEOUT_MS = 30000;
|
|
const RENDERER_READY_POLL_DELAY_MS = 250;
|
|
const BASE_EVALUATE_TIMEOUT_MS = 120000;
|
|
const PER_OP_EVALUATE_TIMEOUT_MS = 250;
|
|
const FALLBACK_PAGE_NAME = 'op-sim-scratch';
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
'Usage: node scripts/logseq-electron-op-sim.cjs [options]',
|
|
'',
|
|
'Options:',
|
|
' --ops <n> Total operations to execute (must be >= 200, default: 200)',
|
|
' --port <n> Electron remote debug port (default: 9333)',
|
|
' --undo-redo-delay-ms <n> Wait time after undo/redo command (default: 350)',
|
|
' -h, --help Show this message',
|
|
'',
|
|
'Prerequisite: start Logseq Electron with --remote-debugging-port=<port>.',
|
|
].join('\n');
|
|
}
|
|
|
|
function wsAddListener(ws, event, handler) {
|
|
if (typeof ws.addEventListener === 'function') {
|
|
ws.addEventListener(event, handler);
|
|
return;
|
|
}
|
|
|
|
ws.on(event, (...args) => {
|
|
if (event === 'message') {
|
|
const payload = typeof args[0] === 'string' ? args[0] : args[0].toString();
|
|
handler({ data: payload });
|
|
return;
|
|
}
|
|
handler(...args);
|
|
});
|
|
}
|
|
|
|
function createCdpClient(ws) {
|
|
let id = 0;
|
|
const pending = new Map();
|
|
|
|
wsAddListener(ws, 'message', (event) => {
|
|
const message = JSON.parse(event.data);
|
|
if (!message.id) return;
|
|
|
|
const callbacks = pending.get(message.id);
|
|
if (!callbacks) return;
|
|
pending.delete(message.id);
|
|
|
|
if (message.error) {
|
|
callbacks.reject(new Error(`CDP error on ${callbacks.method}: ${message.error.message}`));
|
|
} else {
|
|
callbacks.resolve(message.result);
|
|
}
|
|
});
|
|
|
|
wsAddListener(ws, 'close', () => {
|
|
for (const entry of pending.values()) {
|
|
entry.reject(new Error('CDP connection closed before response'));
|
|
}
|
|
pending.clear();
|
|
});
|
|
|
|
return {
|
|
send(method, params = {}, timeoutMs = 20000) {
|
|
const requestId = ++id;
|
|
const payload = JSON.stringify({ id: requestId, method, params });
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
pending.delete(requestId);
|
|
reject(new Error(`Timeout waiting for ${method}`));
|
|
}, timeoutMs);
|
|
|
|
pending.set(requestId, {
|
|
method,
|
|
resolve: (result) => {
|
|
clearTimeout(timeout);
|
|
resolve(result);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
},
|
|
});
|
|
|
|
ws.send(payload);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function pickPageTarget(targets) {
|
|
const pageTargets = targets.filter(
|
|
(target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string'
|
|
);
|
|
if (pageTargets.length === 0) {
|
|
throw new Error('No page target found on debug endpoint');
|
|
}
|
|
|
|
return (
|
|
pageTargets.find((target) => (target.title || '').includes(DEFAULT_TARGET_TITLE)) ||
|
|
pageTargets[0]
|
|
);
|
|
}
|
|
|
|
function listPageTargets(targets) {
|
|
return targets
|
|
.filter((target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string')
|
|
.sort((a, b) => {
|
|
const aPreferred = (a.title || '').includes(DEFAULT_TARGET_TITLE) ? 1 : 0;
|
|
const bPreferred = (b.title || '').includes(DEFAULT_TARGET_TITLE) ? 1 : 0;
|
|
return bPreferred - aPreferred;
|
|
});
|
|
}
|
|
|
|
function closeWebSocketQuietly(ws) {
|
|
if (!ws) return;
|
|
try {
|
|
ws.close();
|
|
} catch (_error) {
|
|
// ignore close errors
|
|
}
|
|
}
|
|
|
|
async function targetHasLogseqApi(cdp) {
|
|
const evaluation = await cdp.send(
|
|
'Runtime.evaluate',
|
|
{
|
|
expression: `(() => {
|
|
const api = globalThis.logseq?.api;
|
|
return !!(
|
|
api &&
|
|
typeof api.get_current_block === 'function' &&
|
|
(
|
|
typeof api.get_current_page === 'function' ||
|
|
typeof api.get_today_page === 'function'
|
|
) &&
|
|
typeof api.append_block_in_page === 'function'
|
|
);
|
|
})()`,
|
|
returnByValue: true,
|
|
awaitPromise: false,
|
|
},
|
|
10000,
|
|
);
|
|
return evaluation?.result?.value === true;
|
|
}
|
|
|
|
function buildRendererProgram(config) {
|
|
return `(() => (async () => {
|
|
const config = ${JSON.stringify(config)};
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const randomItem = (items) => items[Math.floor(Math.random() * items.length)];
|
|
const shuffle = (items) => [...items].sort(() => Math.random() - 0.5);
|
|
const describeError = (error) => String(error?.message || error);
|
|
const asPageName = (pageLike) => {
|
|
if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
|
|
if (!pageLike || typeof pageLike !== 'object') return null;
|
|
if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
|
|
if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
|
|
if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
|
|
return null;
|
|
};
|
|
|
|
const waitForEditorReady = async () => {
|
|
const deadline = Date.now() + config.readyTimeoutMs;
|
|
let lastError = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if (
|
|
globalThis.logseq?.api &&
|
|
typeof logseq.api.get_current_block === 'function' &&
|
|
(
|
|
typeof logseq.api.get_current_page === 'function' ||
|
|
typeof logseq.api.get_today_page === 'function'
|
|
) &&
|
|
typeof logseq.api.append_block_in_page === 'function'
|
|
) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
await sleep(config.readyPollDelayMs);
|
|
}
|
|
|
|
if (lastError) {
|
|
throw new Error('Logseq editor readiness timed out: ' + describeError(lastError));
|
|
}
|
|
throw new Error('Logseq editor readiness timed out: logseq.api is unavailable');
|
|
};
|
|
|
|
const runPrefix =
|
|
typeof config.runPrefix === 'string' && config.runPrefix.length > 0
|
|
? config.runPrefix
|
|
: config.markerPrefix;
|
|
|
|
const chooseRunnableOperation = (requestedOperation, operableCount) => {
|
|
if (requestedOperation === 'move' || requestedOperation === 'delete') {
|
|
return operableCount >= 2 ? requestedOperation : 'add';
|
|
}
|
|
return requestedOperation;
|
|
};
|
|
|
|
const flattenBlocks = (nodes, acc = []) => {
|
|
if (!Array.isArray(nodes)) return acc;
|
|
for (const node of nodes) {
|
|
if (!node) continue;
|
|
acc.push(node);
|
|
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
flattenBlocks(node.children, acc);
|
|
}
|
|
}
|
|
return acc;
|
|
};
|
|
|
|
const isClientBlock = (block) =>
|
|
typeof block?.content === 'string' && block.content.startsWith(config.markerPrefix);
|
|
|
|
const isOperableBlock = (block) =>
|
|
typeof block?.content === 'string' && block.content.startsWith(runPrefix);
|
|
|
|
const isClientRootBlock = (block) =>
|
|
typeof block?.content === 'string' && block.content === (config.markerPrefix + ' client-root');
|
|
|
|
const listPageBlocks = async () => {
|
|
const tree = await logseq.api.get_current_page_blocks_tree();
|
|
return flattenBlocks(tree, []);
|
|
};
|
|
|
|
const listOperableBlocks = async () => {
|
|
const flattened = await listPageBlocks();
|
|
return flattened.filter(isOperableBlock);
|
|
};
|
|
|
|
const listManagedBlocks = async () => {
|
|
const operableBlocks = await listOperableBlocks();
|
|
return operableBlocks.filter(isClientBlock);
|
|
};
|
|
|
|
const ensureClientRootBlock = async (anchorBlock) => {
|
|
const existing = (await listOperableBlocks()).find(isClientRootBlock);
|
|
if (existing?.uuid) return existing;
|
|
const inserted = await logseq.api.insert_block(anchorBlock.uuid, config.markerPrefix + ' client-root', {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
if (!inserted?.uuid) {
|
|
throw new Error('Failed to create client root block');
|
|
}
|
|
return inserted;
|
|
};
|
|
|
|
const pickIndentCandidate = async (blocks) => {
|
|
for (const candidate of shuffle(blocks)) {
|
|
const prev = await logseq.api.get_previous_sibling_block(candidate.uuid);
|
|
if (prev?.uuid) return candidate;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const pickOutdentCandidate = async (blocks) => {
|
|
for (const candidate of shuffle(blocks)) {
|
|
const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
|
|
const parentId = full?.parent?.id;
|
|
const pageId = full?.page?.id;
|
|
if (parentId && pageId && parentId !== pageId) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getPreviousSiblingUuid = async (uuid) => {
|
|
const prev = await logseq.api.get_previous_sibling_block(uuid);
|
|
return prev?.uuid || null;
|
|
};
|
|
|
|
const ensureIndentCandidate = async (blocks, anchorBlock, opIndex) => {
|
|
const existing = await pickIndentCandidate(blocks);
|
|
if (existing?.uuid) return existing;
|
|
|
|
const baseTarget = blocks.length > 0 ? randomItem(blocks) : anchorBlock;
|
|
const base = await logseq.api.insert_block(baseTarget.uuid, config.markerPrefix + ' indent-base-' + opIndex, {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
if (!base?.uuid) {
|
|
throw new Error('Failed to create indent base block');
|
|
}
|
|
|
|
const candidate = await logseq.api.insert_block(base.uuid, config.markerPrefix + ' indent-candidate-' + opIndex, {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
if (!candidate?.uuid) {
|
|
throw new Error('Failed to create indent candidate block');
|
|
}
|
|
return candidate;
|
|
};
|
|
|
|
const runIndent = async (candidate) => {
|
|
const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
|
|
if (!prevUuid) {
|
|
throw new Error('No previous sibling for indent candidate');
|
|
}
|
|
await logseq.api.move_block(candidate.uuid, prevUuid, {
|
|
before: false,
|
|
children: true,
|
|
});
|
|
};
|
|
|
|
const ensureOutdentCandidate = async (blocks, anchorBlock, opIndex) => {
|
|
const existing = await pickOutdentCandidate(blocks);
|
|
if (existing?.uuid) return existing;
|
|
|
|
const candidate = await ensureIndentCandidate(blocks, anchorBlock, opIndex);
|
|
await runIndent(candidate);
|
|
return candidate;
|
|
};
|
|
|
|
const runOutdent = async (candidate) => {
|
|
const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
|
|
const parentId = full?.parent?.id;
|
|
const pageId = full?.page?.id;
|
|
if (!parentId || !pageId || parentId === pageId) {
|
|
throw new Error('Outdent candidate is not nested');
|
|
}
|
|
const parent = await logseq.api.get_block(parentId, { includeChildren: false });
|
|
if (!parent?.uuid) {
|
|
throw new Error('Cannot resolve parent block for outdent');
|
|
}
|
|
await logseq.api.move_block(candidate.uuid, parent.uuid, {
|
|
before: false,
|
|
children: false,
|
|
});
|
|
};
|
|
|
|
const pickRandomGroup = (blocks, minSize = 1, maxSize = 3) => {
|
|
const pool = shuffle(blocks);
|
|
const lower = Math.max(1, Math.min(minSize, pool.length));
|
|
const upper = Math.max(lower, Math.min(maxSize, pool.length));
|
|
const size = lower + Math.floor(Math.random() * (upper - lower + 1));
|
|
return pool.slice(0, size);
|
|
};
|
|
|
|
const toBatchTree = (block) => ({
|
|
content: typeof block?.content === 'string' ? block.content : '',
|
|
children: Array.isArray(block?.children) ? block.children.map(toBatchTree) : [],
|
|
});
|
|
|
|
const getAnchor = async () => {
|
|
const deadline = Date.now() + config.readyTimeoutMs;
|
|
let lastError = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const currentBlock = await logseq.api.get_current_block();
|
|
if (currentBlock && currentBlock.uuid) {
|
|
return currentBlock;
|
|
}
|
|
|
|
if (typeof logseq.api.get_current_page === 'function') {
|
|
const currentPage = await logseq.api.get_current_page();
|
|
const currentPageName = asPageName(currentPage);
|
|
if (currentPageName) {
|
|
const seeded = await logseq.api.append_block_in_page(
|
|
currentPageName,
|
|
config.markerPrefix + ' anchor',
|
|
{}
|
|
);
|
|
if (seeded?.uuid) return seeded;
|
|
}
|
|
}
|
|
|
|
if (typeof logseq.api.get_today_page === 'function') {
|
|
const todayPage = await logseq.api.get_today_page();
|
|
const todayPageName = asPageName(todayPage);
|
|
if (todayPageName) {
|
|
const seeded = await logseq.api.append_block_in_page(
|
|
todayPageName,
|
|
config.markerPrefix + ' anchor',
|
|
{}
|
|
);
|
|
if (seeded?.uuid) return seeded;
|
|
}
|
|
}
|
|
|
|
{
|
|
const seeded = await logseq.api.append_block_in_page(
|
|
config.fallbackPageName,
|
|
config.markerPrefix + ' anchor',
|
|
{}
|
|
);
|
|
if (seeded?.uuid) return seeded;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
await sleep(config.readyPollDelayMs);
|
|
}
|
|
|
|
if (lastError) {
|
|
throw new Error('Unable to resolve anchor block: ' + describeError(lastError));
|
|
}
|
|
throw new Error('Unable to resolve anchor block: open a graph and page, then retry');
|
|
};
|
|
|
|
const counts = {
|
|
add: 0,
|
|
delete: 0,
|
|
move: 0,
|
|
indent: 0,
|
|
outdent: 0,
|
|
undo: 0,
|
|
redo: 0,
|
|
copyPaste: 0,
|
|
copyPasteTreeToEmptyTarget: 0,
|
|
fallbackAdd: 0,
|
|
errors: 0,
|
|
};
|
|
|
|
const errors = [];
|
|
const operationLog = [];
|
|
|
|
await waitForEditorReady();
|
|
const anchor = await getAnchor();
|
|
await ensureClientRootBlock(anchor);
|
|
|
|
if (!(await listManagedBlocks()).length) {
|
|
await logseq.api.insert_block(anchor.uuid, config.markerPrefix + ' seed', {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
|
|
let executed = 0;
|
|
|
|
for (let i = 0; i < config.plan.length; i += 1) {
|
|
const requested = config.plan[i];
|
|
const operable = await listOperableBlocks();
|
|
let operation = chooseRunnableOperation(requested, operable.length);
|
|
if (operation !== requested) {
|
|
counts.fallbackAdd += 1;
|
|
}
|
|
|
|
try {
|
|
await sleep(Math.floor(Math.random() * 40));
|
|
|
|
if (operation === 'add') {
|
|
const target = operable.length > 0 ? randomItem(operable) : anchor;
|
|
const content = Math.random() < 0.2 ? '' : config.markerPrefix + ' add-' + i;
|
|
const asChild = operable.length > 0 && Math.random() < 0.35;
|
|
await logseq.api.insert_block(target.uuid, content, {
|
|
sibling: !asChild,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
|
|
if (operation === 'copyPaste') {
|
|
const pageBlocks = await listPageBlocks();
|
|
const copyPool = (operable.length > 0 ? operable : pageBlocks).filter((b) => b?.uuid);
|
|
if (copyPool.length === 0) {
|
|
throw new Error('No blocks available for copyPaste');
|
|
}
|
|
const source = randomItem(copyPool);
|
|
const target = randomItem(copyPool);
|
|
await logseq.api.select_block(source.uuid);
|
|
await logseq.api.invoke_external_command('logseq.editor/copy');
|
|
const latestSource = await logseq.api.get_block(source.uuid);
|
|
const sourceContent = latestSource?.content || source.content || '';
|
|
const copiedContent =
|
|
config.markerPrefix + ' copy-' + i + (sourceContent ? ' :: ' + sourceContent : '');
|
|
await logseq.api.insert_block(target.uuid, copiedContent, {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
}
|
|
|
|
if (operation === 'copyPasteTreeToEmptyTarget') {
|
|
const pageBlocks = await listPageBlocks();
|
|
const treePool = (operable.length >= 2 ? operable : pageBlocks).filter((b) => b?.uuid);
|
|
if (treePool.length < 2) {
|
|
throw new Error('Not enough blocks available for multi-block copy');
|
|
}
|
|
const sources = pickRandomGroup(treePool, 2, 4);
|
|
const sourceTrees = [];
|
|
for (const source of sources) {
|
|
const sourceTree = await logseq.api.get_block(source.uuid, { includeChildren: true });
|
|
if (sourceTree?.uuid) {
|
|
sourceTrees.push(sourceTree);
|
|
}
|
|
}
|
|
if (sourceTrees.length === 0) {
|
|
throw new Error('Failed to load source tree blocks');
|
|
}
|
|
|
|
const treeTarget = operable.length > 0 ? randomItem(operable) : anchor;
|
|
const emptyTarget = await logseq.api.insert_block(treeTarget.uuid, '', {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
if (!emptyTarget?.uuid) {
|
|
throw new Error('Failed to create empty target block');
|
|
}
|
|
|
|
await logseq.api.update_block(emptyTarget.uuid, '');
|
|
const payload = sourceTrees.map((tree, idx) => {
|
|
const node = toBatchTree(tree);
|
|
const origin = typeof node.content === 'string' && node.content.length > 0
|
|
? ' :: ' + node.content
|
|
: '';
|
|
node.content = config.markerPrefix + ' tree-copy-' + i + '-' + idx + origin;
|
|
return node;
|
|
});
|
|
try {
|
|
await logseq.api.insert_batch_block(emptyTarget.uuid, payload, { sibling: false });
|
|
} catch (_error) {
|
|
for (const tree of sourceTrees) {
|
|
await logseq.api.insert_batch_block(emptyTarget.uuid, toBatchTree(tree), { sibling: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (operation === 'move') {
|
|
const source = randomItem(operable);
|
|
const candidates = operable.filter((block) => block.uuid !== source.uuid);
|
|
const target = randomItem(candidates);
|
|
await logseq.api.move_block(source.uuid, target.uuid, {
|
|
before: Math.random() < 0.5,
|
|
children: false,
|
|
});
|
|
}
|
|
|
|
if (operation === 'indent') {
|
|
const candidate = await ensureIndentCandidate(operable, anchor, i);
|
|
await runIndent(candidate);
|
|
}
|
|
|
|
if (operation === 'outdent') {
|
|
const candidate = await ensureOutdentCandidate(operable, anchor, i);
|
|
await runOutdent(candidate);
|
|
}
|
|
|
|
if (operation === 'delete') {
|
|
const candidates = operable.filter((block) => block.uuid !== anchor.uuid && !isClientRootBlock(block));
|
|
const victimPool = candidates.length > 0 ? candidates : operable;
|
|
const victim = randomItem(victimPool);
|
|
if (isClientRootBlock(victim)) {
|
|
throw new Error('Skip deleting protected client root block');
|
|
}
|
|
await logseq.api.remove_block(victim.uuid);
|
|
}
|
|
|
|
if (operation === 'undo') {
|
|
await logseq.api.invoke_external_command('logseq.editor/undo');
|
|
await sleep(config.undoRedoDelayMs);
|
|
}
|
|
|
|
if (operation === 'redo') {
|
|
await logseq.api.invoke_external_command('logseq.editor/redo');
|
|
await sleep(config.undoRedoDelayMs);
|
|
}
|
|
|
|
counts[operation] += 1;
|
|
executed += 1;
|
|
operationLog.push({ index: i, requested, executedAs: operation });
|
|
} catch (error) {
|
|
counts.errors += 1;
|
|
errors.push({
|
|
index: i,
|
|
requested,
|
|
attempted: operation,
|
|
message: String(error?.message || error),
|
|
});
|
|
|
|
try {
|
|
const recoveryOperable = await listOperableBlocks();
|
|
const target = recoveryOperable.length > 0 ? randomItem(recoveryOperable) : anchor;
|
|
await logseq.api.insert_block(target.uuid, config.markerPrefix + ' recovery-' + i, {
|
|
sibling: true,
|
|
before: false,
|
|
focus: false,
|
|
});
|
|
counts.add += 1;
|
|
executed += 1;
|
|
operationLog.push({ index: i, requested, executedAs: 'add' });
|
|
} catch (recoveryError) {
|
|
errors.push({
|
|
index: i,
|
|
requested,
|
|
attempted: 'recovery-add',
|
|
message: String(recoveryError?.message || recoveryError),
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalManaged = await listManagedBlocks();
|
|
return {
|
|
ok: true,
|
|
requestedOps: config.plan.length,
|
|
executedOps: executed,
|
|
counts,
|
|
markerPrefix: config.markerPrefix,
|
|
anchorUuid: anchor.uuid,
|
|
finalManagedCount: finalManaged.length,
|
|
sampleManaged: finalManaged.slice(0, 5).map((block) => ({
|
|
uuid: block.uuid,
|
|
content: block.content,
|
|
})),
|
|
errorCount: errors.length,
|
|
errors: errors.slice(0, 20),
|
|
opLogSample: operationLog.slice(0, 20),
|
|
};
|
|
})())()`;
|
|
}
|
|
|
|
async function openWebSocket(url) {
|
|
if (!WebSocketCtor) {
|
|
throw new Error('Global WebSocket is unavailable in this Node runtime.');
|
|
}
|
|
|
|
const ws = new WebSocketCtor(url);
|
|
await new Promise((resolve, reject) => {
|
|
wsAddListener(ws, 'open', resolve);
|
|
wsAddListener(ws, 'error', reject);
|
|
});
|
|
return ws;
|
|
}
|
|
|
|
async function fetchDebugTargets(port) {
|
|
const endpoint = `http://127.0.0.1:${port}/json/list`;
|
|
const response = await fetch(endpoint);
|
|
if (!response.ok) {
|
|
throw new Error(`Debug endpoint returned HTTP ${response.status} for ${endpoint}`);
|
|
}
|
|
|
|
const targets = await response.json();
|
|
if (!Array.isArray(targets)) {
|
|
throw new Error('Debug endpoint returned an invalid target list');
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
async function connectToLogseqPageWebSocket(port) {
|
|
const deadline = Date.now() + DEBUG_TARGET_WAIT_TIMEOUT_MS;
|
|
let lastError = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const targets = await fetchDebugTargets(port);
|
|
const pageTargets = listPageTargets(targets);
|
|
if (pageTargets.length === 0) {
|
|
throw new Error('No page target found on debug endpoint');
|
|
}
|
|
|
|
let lastTargetError = null;
|
|
for (const target of pageTargets) {
|
|
let ws = null;
|
|
try {
|
|
ws = await openWebSocket(target.webSocketDebuggerUrl);
|
|
const cdp = createCdpClient(ws);
|
|
await cdp.send('Runtime.enable');
|
|
const hasLogseqApi = await targetHasLogseqApi(cdp);
|
|
if (hasLogseqApi) {
|
|
return { ws, cdp };
|
|
}
|
|
closeWebSocketQuietly(ws);
|
|
} catch (error) {
|
|
lastTargetError = error;
|
|
closeWebSocketQuietly(ws);
|
|
}
|
|
}
|
|
|
|
throw lastTargetError || new Error('No page target exposes logseq.api yet');
|
|
} catch (error) {
|
|
lastError = error;
|
|
await sleep(DEBUG_TARGET_RETRY_DELAY_MS);
|
|
}
|
|
}
|
|
|
|
const suffix = lastError ? ` Last error: ${String(lastError.message || lastError)}` : '';
|
|
throw new Error(
|
|
`Unable to connect to a Logseq page target within ${DEBUG_TARGET_WAIT_TIMEOUT_MS}ms.` + suffix
|
|
);
|
|
}
|
|
|
|
function computeEvaluateTimeoutMs(args) {
|
|
return BASE_EVALUATE_TIMEOUT_MS + args.ops * PER_OP_EVALUATE_TIMEOUT_MS;
|
|
}
|
|
|
|
function shuffleOperationPlan(plan) {
|
|
const shuffled = Array.isArray(plan) ? [...plan] : [];
|
|
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
const tmp = shuffled[i];
|
|
shuffled[i] = shuffled[j];
|
|
shuffled[j] = tmp;
|
|
}
|
|
return shuffled;
|
|
}
|
|
|
|
async function main() {
|
|
let args;
|
|
try {
|
|
args = parseArgs(process.argv.slice(2));
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
console.error('\n' + usage());
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
if (args.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
const runPrefix = `op-sim-${Date.now()}-`;
|
|
const plan = shuffleOperationPlan(buildOperationPlan(args.ops));
|
|
const markerPrefix = `${runPrefix}client-1-`;
|
|
const { ws, cdp } = await connectToLogseqPageWebSocket(args.port);
|
|
let evaluation;
|
|
try {
|
|
evaluation = await cdp.send(
|
|
'Runtime.evaluate',
|
|
{
|
|
expression: buildRendererProgram({
|
|
runPrefix,
|
|
markerPrefix,
|
|
plan,
|
|
undoRedoDelayMs: args.undoRedoDelayMs,
|
|
readyTimeoutMs: RENDERER_READY_TIMEOUT_MS,
|
|
readyPollDelayMs: RENDERER_READY_POLL_DELAY_MS,
|
|
fallbackPageName: FALLBACK_PAGE_NAME,
|
|
}),
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
},
|
|
computeEvaluateTimeoutMs(args),
|
|
);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
|
|
if (evaluation?.exceptionDetails) {
|
|
const detail = evaluation.exceptionDetails.text || evaluation.exceptionDetails.exception?.description;
|
|
throw new Error(`Runtime.evaluate failed: ${detail || 'unknown renderer exception'}`);
|
|
}
|
|
const value = evaluation?.result?.value;
|
|
if (!value) {
|
|
throw new Error('Unexpected empty Runtime.evaluate result');
|
|
}
|
|
|
|
console.log(JSON.stringify(value, null, 2));
|
|
|
|
if (!value.ok || value.executedOps < args.ops) {
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || String(error));
|
|
process.exit(1);
|
|
});
|