1
0

sync-open-electrion-simulate.cjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793
  1. #!/usr/bin/env node
  2. 'use strict';
  3. const {
  4. buildOperationPlan,
  5. parseArgs,
  6. } = require('./lib/logseq-electron-op-sim.cjs');
  7. const DEFAULT_TARGET_TITLE = 'Logseq';
  8. const WebSocketCtor = globalThis.WebSocket;
  9. const DEBUG_TARGET_WAIT_TIMEOUT_MS = 30000;
  10. const DEBUG_TARGET_RETRY_DELAY_MS = 300;
  11. const RENDERER_READY_TIMEOUT_MS = 30000;
  12. const RENDERER_READY_POLL_DELAY_MS = 250;
  13. const BASE_EVALUATE_TIMEOUT_MS = 120000;
  14. const PER_OP_EVALUATE_TIMEOUT_MS = 250;
  15. const FALLBACK_PAGE_NAME = 'op-sim-scratch';
  16. function sleep(ms) {
  17. return new Promise((resolve) => setTimeout(resolve, ms));
  18. }
  19. function usage() {
  20. return [
  21. 'Usage: node scripts/logseq-electron-op-sim.cjs [options]',
  22. '',
  23. 'Options:',
  24. ' --ops <n> Total operations to execute (must be >= 200, default: 200)',
  25. ' --port <n> Electron remote debug port (default: 9333)',
  26. ' --undo-redo-delay-ms <n> Wait time after undo/redo command (default: 350)',
  27. ' -h, --help Show this message',
  28. '',
  29. 'Prerequisite: start Logseq Electron with --remote-debugging-port=<port>.',
  30. ].join('\n');
  31. }
  32. function wsAddListener(ws, event, handler) {
  33. if (typeof ws.addEventListener === 'function') {
  34. ws.addEventListener(event, handler);
  35. return;
  36. }
  37. ws.on(event, (...args) => {
  38. if (event === 'message') {
  39. const payload = typeof args[0] === 'string' ? args[0] : args[0].toString();
  40. handler({ data: payload });
  41. return;
  42. }
  43. handler(...args);
  44. });
  45. }
  46. function createCdpClient(ws) {
  47. let id = 0;
  48. const pending = new Map();
  49. wsAddListener(ws, 'message', (event) => {
  50. const message = JSON.parse(event.data);
  51. if (!message.id) return;
  52. const callbacks = pending.get(message.id);
  53. if (!callbacks) return;
  54. pending.delete(message.id);
  55. if (message.error) {
  56. callbacks.reject(new Error(`CDP error on ${callbacks.method}: ${message.error.message}`));
  57. } else {
  58. callbacks.resolve(message.result);
  59. }
  60. });
  61. wsAddListener(ws, 'close', () => {
  62. for (const entry of pending.values()) {
  63. entry.reject(new Error('CDP connection closed before response'));
  64. }
  65. pending.clear();
  66. });
  67. return {
  68. send(method, params = {}, timeoutMs = 20000) {
  69. const requestId = ++id;
  70. const payload = JSON.stringify({ id: requestId, method, params });
  71. return new Promise((resolve, reject) => {
  72. const timeout = setTimeout(() => {
  73. pending.delete(requestId);
  74. reject(new Error(`Timeout waiting for ${method}`));
  75. }, timeoutMs);
  76. pending.set(requestId, {
  77. method,
  78. resolve: (result) => {
  79. clearTimeout(timeout);
  80. resolve(result);
  81. },
  82. reject: (error) => {
  83. clearTimeout(timeout);
  84. reject(error);
  85. },
  86. });
  87. ws.send(payload);
  88. });
  89. },
  90. };
  91. }
  92. function pickPageTarget(targets) {
  93. const pageTargets = targets.filter(
  94. (target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string'
  95. );
  96. if (pageTargets.length === 0) {
  97. throw new Error('No page target found on debug endpoint');
  98. }
  99. return (
  100. pageTargets.find((target) => (target.title || '').includes(DEFAULT_TARGET_TITLE)) ||
  101. pageTargets[0]
  102. );
  103. }
  104. function listPageTargets(targets) {
  105. return targets
  106. .filter((target) => target.type === 'page' && typeof target.webSocketDebuggerUrl === 'string')
  107. .sort((a, b) => {
  108. const aPreferred = (a.title || '').includes(DEFAULT_TARGET_TITLE) ? 1 : 0;
  109. const bPreferred = (b.title || '').includes(DEFAULT_TARGET_TITLE) ? 1 : 0;
  110. return bPreferred - aPreferred;
  111. });
  112. }
  113. function closeWebSocketQuietly(ws) {
  114. if (!ws) return;
  115. try {
  116. ws.close();
  117. } catch (_error) {
  118. // ignore close errors
  119. }
  120. }
  121. async function targetHasLogseqApi(cdp) {
  122. const evaluation = await cdp.send(
  123. 'Runtime.evaluate',
  124. {
  125. expression: `(() => {
  126. const api = globalThis.logseq?.api;
  127. return !!(
  128. api &&
  129. typeof api.get_current_block === 'function' &&
  130. (
  131. typeof api.get_current_page === 'function' ||
  132. typeof api.get_today_page === 'function'
  133. ) &&
  134. typeof api.append_block_in_page === 'function'
  135. );
  136. })()`,
  137. returnByValue: true,
  138. awaitPromise: false,
  139. },
  140. 10000,
  141. );
  142. return evaluation?.result?.value === true;
  143. }
  144. function buildRendererProgram(config) {
  145. return `(() => (async () => {
  146. const config = ${JSON.stringify(config)};
  147. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  148. const randomItem = (items) => items[Math.floor(Math.random() * items.length)];
  149. const shuffle = (items) => [...items].sort(() => Math.random() - 0.5);
  150. const describeError = (error) => String(error?.message || error);
  151. const asPageName = (pageLike) => {
  152. if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
  153. if (!pageLike || typeof pageLike !== 'object') return null;
  154. if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
  155. if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
  156. if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
  157. return null;
  158. };
  159. const waitForEditorReady = async () => {
  160. const deadline = Date.now() + config.readyTimeoutMs;
  161. let lastError = null;
  162. while (Date.now() < deadline) {
  163. try {
  164. if (
  165. globalThis.logseq?.api &&
  166. typeof logseq.api.get_current_block === 'function' &&
  167. (
  168. typeof logseq.api.get_current_page === 'function' ||
  169. typeof logseq.api.get_today_page === 'function'
  170. ) &&
  171. typeof logseq.api.append_block_in_page === 'function'
  172. ) {
  173. return;
  174. }
  175. } catch (error) {
  176. lastError = error;
  177. }
  178. await sleep(config.readyPollDelayMs);
  179. }
  180. if (lastError) {
  181. throw new Error('Logseq editor readiness timed out: ' + describeError(lastError));
  182. }
  183. throw new Error('Logseq editor readiness timed out: logseq.api is unavailable');
  184. };
  185. const runPrefix =
  186. typeof config.runPrefix === 'string' && config.runPrefix.length > 0
  187. ? config.runPrefix
  188. : config.markerPrefix;
  189. const chooseRunnableOperation = (requestedOperation, operableCount) => {
  190. if (requestedOperation === 'move' || requestedOperation === 'delete') {
  191. return operableCount >= 2 ? requestedOperation : 'add';
  192. }
  193. return requestedOperation;
  194. };
  195. const flattenBlocks = (nodes, acc = []) => {
  196. if (!Array.isArray(nodes)) return acc;
  197. for (const node of nodes) {
  198. if (!node) continue;
  199. acc.push(node);
  200. if (Array.isArray(node.children) && node.children.length > 0) {
  201. flattenBlocks(node.children, acc);
  202. }
  203. }
  204. return acc;
  205. };
  206. const isClientBlock = (block) =>
  207. typeof block?.content === 'string' && block.content.startsWith(config.markerPrefix);
  208. const isOperableBlock = (block) =>
  209. typeof block?.content === 'string' && block.content.startsWith(runPrefix);
  210. const isClientRootBlock = (block) =>
  211. typeof block?.content === 'string' && block.content === (config.markerPrefix + ' client-root');
  212. const listPageBlocks = async () => {
  213. const tree = await logseq.api.get_current_page_blocks_tree();
  214. return flattenBlocks(tree, []);
  215. };
  216. const listOperableBlocks = async () => {
  217. const flattened = await listPageBlocks();
  218. return flattened.filter(isOperableBlock);
  219. };
  220. const listManagedBlocks = async () => {
  221. const operableBlocks = await listOperableBlocks();
  222. return operableBlocks.filter(isClientBlock);
  223. };
  224. const ensureClientRootBlock = async (anchorBlock) => {
  225. const existing = (await listOperableBlocks()).find(isClientRootBlock);
  226. if (existing?.uuid) return existing;
  227. const inserted = await logseq.api.insert_block(anchorBlock.uuid, config.markerPrefix + ' client-root', {
  228. sibling: true,
  229. before: false,
  230. focus: false,
  231. });
  232. if (!inserted?.uuid) {
  233. throw new Error('Failed to create client root block');
  234. }
  235. return inserted;
  236. };
  237. const pickIndentCandidate = async (blocks) => {
  238. for (const candidate of shuffle(blocks)) {
  239. const prev = await logseq.api.get_previous_sibling_block(candidate.uuid);
  240. if (prev?.uuid) return candidate;
  241. }
  242. return null;
  243. };
  244. const pickOutdentCandidate = async (blocks) => {
  245. for (const candidate of shuffle(blocks)) {
  246. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  247. const parentId = full?.parent?.id;
  248. const pageId = full?.page?.id;
  249. if (parentId && pageId && parentId !== pageId) {
  250. return candidate;
  251. }
  252. }
  253. return null;
  254. };
  255. const getPreviousSiblingUuid = async (uuid) => {
  256. const prev = await logseq.api.get_previous_sibling_block(uuid);
  257. return prev?.uuid || null;
  258. };
  259. const ensureIndentCandidate = async (blocks, anchorBlock, opIndex) => {
  260. const existing = await pickIndentCandidate(blocks);
  261. if (existing?.uuid) return existing;
  262. const baseTarget = blocks.length > 0 ? randomItem(blocks) : anchorBlock;
  263. const base = await logseq.api.insert_block(baseTarget.uuid, config.markerPrefix + ' indent-base-' + opIndex, {
  264. sibling: true,
  265. before: false,
  266. focus: false,
  267. });
  268. if (!base?.uuid) {
  269. throw new Error('Failed to create indent base block');
  270. }
  271. const candidate = await logseq.api.insert_block(base.uuid, config.markerPrefix + ' indent-candidate-' + opIndex, {
  272. sibling: true,
  273. before: false,
  274. focus: false,
  275. });
  276. if (!candidate?.uuid) {
  277. throw new Error('Failed to create indent candidate block');
  278. }
  279. return candidate;
  280. };
  281. const runIndent = async (candidate) => {
  282. const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
  283. if (!prevUuid) {
  284. throw new Error('No previous sibling for indent candidate');
  285. }
  286. await logseq.api.move_block(candidate.uuid, prevUuid, {
  287. before: false,
  288. children: true,
  289. });
  290. };
  291. const ensureOutdentCandidate = async (blocks, anchorBlock, opIndex) => {
  292. const existing = await pickOutdentCandidate(blocks);
  293. if (existing?.uuid) return existing;
  294. const candidate = await ensureIndentCandidate(blocks, anchorBlock, opIndex);
  295. await runIndent(candidate);
  296. return candidate;
  297. };
  298. const runOutdent = async (candidate) => {
  299. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  300. const parentId = full?.parent?.id;
  301. const pageId = full?.page?.id;
  302. if (!parentId || !pageId || parentId === pageId) {
  303. throw new Error('Outdent candidate is not nested');
  304. }
  305. const parent = await logseq.api.get_block(parentId, { includeChildren: false });
  306. if (!parent?.uuid) {
  307. throw new Error('Cannot resolve parent block for outdent');
  308. }
  309. await logseq.api.move_block(candidate.uuid, parent.uuid, {
  310. before: false,
  311. children: false,
  312. });
  313. };
  314. const pickRandomGroup = (blocks, minSize = 1, maxSize = 3) => {
  315. const pool = shuffle(blocks);
  316. const lower = Math.max(1, Math.min(minSize, pool.length));
  317. const upper = Math.max(lower, Math.min(maxSize, pool.length));
  318. const size = lower + Math.floor(Math.random() * (upper - lower + 1));
  319. return pool.slice(0, size);
  320. };
  321. const toBatchTree = (block) => ({
  322. content: typeof block?.content === 'string' ? block.content : '',
  323. children: Array.isArray(block?.children) ? block.children.map(toBatchTree) : [],
  324. });
  325. const getAnchor = async () => {
  326. const deadline = Date.now() + config.readyTimeoutMs;
  327. let lastError = null;
  328. while (Date.now() < deadline) {
  329. try {
  330. const currentBlock = await logseq.api.get_current_block();
  331. if (currentBlock && currentBlock.uuid) {
  332. return currentBlock;
  333. }
  334. if (typeof logseq.api.get_current_page === 'function') {
  335. const currentPage = await logseq.api.get_current_page();
  336. const currentPageName = asPageName(currentPage);
  337. if (currentPageName) {
  338. const seeded = await logseq.api.append_block_in_page(
  339. currentPageName,
  340. config.markerPrefix + ' anchor',
  341. {}
  342. );
  343. if (seeded?.uuid) return seeded;
  344. }
  345. }
  346. if (typeof logseq.api.get_today_page === 'function') {
  347. const todayPage = await logseq.api.get_today_page();
  348. const todayPageName = asPageName(todayPage);
  349. if (todayPageName) {
  350. const seeded = await logseq.api.append_block_in_page(
  351. todayPageName,
  352. config.markerPrefix + ' anchor',
  353. {}
  354. );
  355. if (seeded?.uuid) return seeded;
  356. }
  357. }
  358. {
  359. const seeded = await logseq.api.append_block_in_page(
  360. config.fallbackPageName,
  361. config.markerPrefix + ' anchor',
  362. {}
  363. );
  364. if (seeded?.uuid) return seeded;
  365. }
  366. } catch (error) {
  367. lastError = error;
  368. }
  369. await sleep(config.readyPollDelayMs);
  370. }
  371. if (lastError) {
  372. throw new Error('Unable to resolve anchor block: ' + describeError(lastError));
  373. }
  374. throw new Error('Unable to resolve anchor block: open a graph and page, then retry');
  375. };
  376. const counts = {
  377. add: 0,
  378. delete: 0,
  379. move: 0,
  380. indent: 0,
  381. outdent: 0,
  382. undo: 0,
  383. redo: 0,
  384. copyPaste: 0,
  385. copyPasteTreeToEmptyTarget: 0,
  386. fallbackAdd: 0,
  387. errors: 0,
  388. };
  389. const errors = [];
  390. const operationLog = [];
  391. await waitForEditorReady();
  392. const anchor = await getAnchor();
  393. await ensureClientRootBlock(anchor);
  394. if (!(await listManagedBlocks()).length) {
  395. await logseq.api.insert_block(anchor.uuid, config.markerPrefix + ' seed', {
  396. sibling: true,
  397. before: false,
  398. focus: false,
  399. });
  400. }
  401. let executed = 0;
  402. for (let i = 0; i < config.plan.length; i += 1) {
  403. const requested = config.plan[i];
  404. const operable = await listOperableBlocks();
  405. let operation = chooseRunnableOperation(requested, operable.length);
  406. if (operation !== requested) {
  407. counts.fallbackAdd += 1;
  408. }
  409. try {
  410. await sleep(Math.floor(Math.random() * 40));
  411. if (operation === 'add') {
  412. const target = operable.length > 0 ? randomItem(operable) : anchor;
  413. const content = Math.random() < 0.2 ? '' : config.markerPrefix + ' add-' + i;
  414. const asChild = operable.length > 0 && Math.random() < 0.35;
  415. await logseq.api.insert_block(target.uuid, content, {
  416. sibling: !asChild,
  417. before: false,
  418. focus: false,
  419. });
  420. }
  421. if (operation === 'copyPaste') {
  422. const pageBlocks = await listPageBlocks();
  423. const copyPool = (operable.length > 0 ? operable : pageBlocks).filter((b) => b?.uuid);
  424. if (copyPool.length === 0) {
  425. throw new Error('No blocks available for copyPaste');
  426. }
  427. const source = randomItem(copyPool);
  428. const target = randomItem(copyPool);
  429. await logseq.api.select_block(source.uuid);
  430. await logseq.api.invoke_external_command('logseq.editor/copy');
  431. const latestSource = await logseq.api.get_block(source.uuid);
  432. const sourceContent = latestSource?.content || source.content || '';
  433. const copiedContent =
  434. config.markerPrefix + ' copy-' + i + (sourceContent ? ' :: ' + sourceContent : '');
  435. await logseq.api.insert_block(target.uuid, copiedContent, {
  436. sibling: true,
  437. before: false,
  438. focus: false,
  439. });
  440. }
  441. if (operation === 'copyPasteTreeToEmptyTarget') {
  442. const pageBlocks = await listPageBlocks();
  443. const treePool = (operable.length >= 2 ? operable : pageBlocks).filter((b) => b?.uuid);
  444. if (treePool.length < 2) {
  445. throw new Error('Not enough blocks available for multi-block copy');
  446. }
  447. const sources = pickRandomGroup(treePool, 2, 4);
  448. const sourceTrees = [];
  449. for (const source of sources) {
  450. const sourceTree = await logseq.api.get_block(source.uuid, { includeChildren: true });
  451. if (sourceTree?.uuid) {
  452. sourceTrees.push(sourceTree);
  453. }
  454. }
  455. if (sourceTrees.length === 0) {
  456. throw new Error('Failed to load source tree blocks');
  457. }
  458. const treeTarget = operable.length > 0 ? randomItem(operable) : anchor;
  459. const emptyTarget = await logseq.api.insert_block(treeTarget.uuid, '', {
  460. sibling: true,
  461. before: false,
  462. focus: false,
  463. });
  464. if (!emptyTarget?.uuid) {
  465. throw new Error('Failed to create empty target block');
  466. }
  467. await logseq.api.update_block(emptyTarget.uuid, '');
  468. const payload = sourceTrees.map((tree, idx) => {
  469. const node = toBatchTree(tree);
  470. const origin = typeof node.content === 'string' && node.content.length > 0
  471. ? ' :: ' + node.content
  472. : '';
  473. node.content = config.markerPrefix + ' tree-copy-' + i + '-' + idx + origin;
  474. return node;
  475. });
  476. try {
  477. await logseq.api.insert_batch_block(emptyTarget.uuid, payload, { sibling: false });
  478. } catch (_error) {
  479. for (const tree of sourceTrees) {
  480. await logseq.api.insert_batch_block(emptyTarget.uuid, toBatchTree(tree), { sibling: false });
  481. }
  482. }
  483. }
  484. if (operation === 'move') {
  485. const source = randomItem(operable);
  486. const candidates = operable.filter((block) => block.uuid !== source.uuid);
  487. const target = randomItem(candidates);
  488. await logseq.api.move_block(source.uuid, target.uuid, {
  489. before: Math.random() < 0.5,
  490. children: false,
  491. });
  492. }
  493. if (operation === 'indent') {
  494. const candidate = await ensureIndentCandidate(operable, anchor, i);
  495. await runIndent(candidate);
  496. }
  497. if (operation === 'outdent') {
  498. const candidate = await ensureOutdentCandidate(operable, anchor, i);
  499. await runOutdent(candidate);
  500. }
  501. if (operation === 'delete') {
  502. const candidates = operable.filter((block) => block.uuid !== anchor.uuid && !isClientRootBlock(block));
  503. const victimPool = candidates.length > 0 ? candidates : operable;
  504. const victim = randomItem(victimPool);
  505. if (isClientRootBlock(victim)) {
  506. throw new Error('Skip deleting protected client root block');
  507. }
  508. await logseq.api.remove_block(victim.uuid);
  509. }
  510. if (operation === 'undo') {
  511. await logseq.api.invoke_external_command('logseq.editor/undo');
  512. await sleep(config.undoRedoDelayMs);
  513. }
  514. if (operation === 'redo') {
  515. await logseq.api.invoke_external_command('logseq.editor/redo');
  516. await sleep(config.undoRedoDelayMs);
  517. }
  518. counts[operation] += 1;
  519. executed += 1;
  520. operationLog.push({ index: i, requested, executedAs: operation });
  521. } catch (error) {
  522. counts.errors += 1;
  523. errors.push({
  524. index: i,
  525. requested,
  526. attempted: operation,
  527. message: String(error?.message || error),
  528. });
  529. try {
  530. const recoveryOperable = await listOperableBlocks();
  531. const target = recoveryOperable.length > 0 ? randomItem(recoveryOperable) : anchor;
  532. await logseq.api.insert_block(target.uuid, config.markerPrefix + ' recovery-' + i, {
  533. sibling: true,
  534. before: false,
  535. focus: false,
  536. });
  537. counts.add += 1;
  538. executed += 1;
  539. operationLog.push({ index: i, requested, executedAs: 'add' });
  540. } catch (recoveryError) {
  541. errors.push({
  542. index: i,
  543. requested,
  544. attempted: 'recovery-add',
  545. message: String(recoveryError?.message || recoveryError),
  546. });
  547. break;
  548. }
  549. }
  550. }
  551. const finalManaged = await listManagedBlocks();
  552. return {
  553. ok: true,
  554. requestedOps: config.plan.length,
  555. executedOps: executed,
  556. counts,
  557. markerPrefix: config.markerPrefix,
  558. anchorUuid: anchor.uuid,
  559. finalManagedCount: finalManaged.length,
  560. sampleManaged: finalManaged.slice(0, 5).map((block) => ({
  561. uuid: block.uuid,
  562. content: block.content,
  563. })),
  564. errorCount: errors.length,
  565. errors: errors.slice(0, 20),
  566. opLogSample: operationLog.slice(0, 20),
  567. };
  568. })())()`;
  569. }
  570. async function openWebSocket(url) {
  571. if (!WebSocketCtor) {
  572. throw new Error('Global WebSocket is unavailable in this Node runtime.');
  573. }
  574. const ws = new WebSocketCtor(url);
  575. await new Promise((resolve, reject) => {
  576. wsAddListener(ws, 'open', resolve);
  577. wsAddListener(ws, 'error', reject);
  578. });
  579. return ws;
  580. }
  581. async function fetchDebugTargets(port) {
  582. const endpoint = `http://127.0.0.1:${port}/json/list`;
  583. const response = await fetch(endpoint);
  584. if (!response.ok) {
  585. throw new Error(`Debug endpoint returned HTTP ${response.status} for ${endpoint}`);
  586. }
  587. const targets = await response.json();
  588. if (!Array.isArray(targets)) {
  589. throw new Error('Debug endpoint returned an invalid target list');
  590. }
  591. return targets;
  592. }
  593. async function connectToLogseqPageWebSocket(port) {
  594. const deadline = Date.now() + DEBUG_TARGET_WAIT_TIMEOUT_MS;
  595. let lastError = null;
  596. while (Date.now() < deadline) {
  597. try {
  598. const targets = await fetchDebugTargets(port);
  599. const pageTargets = listPageTargets(targets);
  600. if (pageTargets.length === 0) {
  601. throw new Error('No page target found on debug endpoint');
  602. }
  603. let lastTargetError = null;
  604. for (const target of pageTargets) {
  605. let ws = null;
  606. try {
  607. ws = await openWebSocket(target.webSocketDebuggerUrl);
  608. const cdp = createCdpClient(ws);
  609. await cdp.send('Runtime.enable');
  610. const hasLogseqApi = await targetHasLogseqApi(cdp);
  611. if (hasLogseqApi) {
  612. return { ws, cdp };
  613. }
  614. closeWebSocketQuietly(ws);
  615. } catch (error) {
  616. lastTargetError = error;
  617. closeWebSocketQuietly(ws);
  618. }
  619. }
  620. throw lastTargetError || new Error('No page target exposes logseq.api yet');
  621. } catch (error) {
  622. lastError = error;
  623. await sleep(DEBUG_TARGET_RETRY_DELAY_MS);
  624. }
  625. }
  626. const suffix = lastError ? ` Last error: ${String(lastError.message || lastError)}` : '';
  627. throw new Error(
  628. `Unable to connect to a Logseq page target within ${DEBUG_TARGET_WAIT_TIMEOUT_MS}ms.` + suffix
  629. );
  630. }
  631. function computeEvaluateTimeoutMs(args) {
  632. return BASE_EVALUATE_TIMEOUT_MS + args.ops * PER_OP_EVALUATE_TIMEOUT_MS;
  633. }
  634. function shuffleOperationPlan(plan) {
  635. const shuffled = Array.isArray(plan) ? [...plan] : [];
  636. for (let i = shuffled.length - 1; i > 0; i -= 1) {
  637. const j = Math.floor(Math.random() * (i + 1));
  638. const tmp = shuffled[i];
  639. shuffled[i] = shuffled[j];
  640. shuffled[j] = tmp;
  641. }
  642. return shuffled;
  643. }
  644. async function main() {
  645. let args;
  646. try {
  647. args = parseArgs(process.argv.slice(2));
  648. } catch (error) {
  649. console.error(error.message);
  650. console.error('\n' + usage());
  651. process.exit(1);
  652. return;
  653. }
  654. if (args.help) {
  655. console.log(usage());
  656. return;
  657. }
  658. const runPrefix = `op-sim-${Date.now()}-`;
  659. const plan = shuffleOperationPlan(buildOperationPlan(args.ops));
  660. const markerPrefix = `${runPrefix}client-1-`;
  661. const { ws, cdp } = await connectToLogseqPageWebSocket(args.port);
  662. let evaluation;
  663. try {
  664. evaluation = await cdp.send(
  665. 'Runtime.evaluate',
  666. {
  667. expression: buildRendererProgram({
  668. runPrefix,
  669. markerPrefix,
  670. plan,
  671. undoRedoDelayMs: args.undoRedoDelayMs,
  672. readyTimeoutMs: RENDERER_READY_TIMEOUT_MS,
  673. readyPollDelayMs: RENDERER_READY_POLL_DELAY_MS,
  674. fallbackPageName: FALLBACK_PAGE_NAME,
  675. }),
  676. awaitPromise: true,
  677. returnByValue: true,
  678. },
  679. computeEvaluateTimeoutMs(args),
  680. );
  681. } finally {
  682. ws.close();
  683. }
  684. if (evaluation?.exceptionDetails) {
  685. const detail = evaluation.exceptionDetails.text || evaluation.exceptionDetails.exception?.description;
  686. throw new Error(`Runtime.evaluate failed: ${detail || 'unknown renderer exception'}`);
  687. }
  688. const value = evaluation?.result?.value;
  689. if (!value) {
  690. throw new Error('Unexpected empty Runtime.evaluate result');
  691. }
  692. console.log(JSON.stringify(value, null, 2));
  693. if (!value.ok || value.executedOps < args.ops) {
  694. process.exitCode = 2;
  695. }
  696. }
  697. main().catch((error) => {
  698. console.error(error.stack || String(error));
  699. process.exit(1);
  700. });