sync-open-chrome-tab-simulate.cjs 130 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949
  1. #!/usr/bin/env node
  2. 'use strict';
  3. const { spawn } = require('node:child_process');
  4. const fs = require('node:fs');
  5. const fsPromises = require('node:fs/promises');
  6. const os = require('node:os');
  7. const path = require('node:path');
  8. const {
  9. buildOperationPlan,
  10. } = require('./lib/logseq-electron-op-sim.cjs');
  11. const DEFAULT_URL = 'http://localhost:3001/#/';
  12. const DEFAULT_SESSION_NAME = 'logseq-op-sim';
  13. const DEFAULT_CHROME_PROFILE = 'auto';
  14. const DEFAULT_INSTANCES = 1;
  15. const DEFAULT_OPS = 50;
  16. const DEFAULT_OP_PROFILE = 'fast';
  17. const DEFAULT_OP_TIMEOUT_MS = 1000;
  18. const DEFAULT_ROUNDS = 1;
  19. const DEFAULT_UNDO_REDO_DELAY_MS = 350;
  20. const DEFAULT_HEADED = true;
  21. const DEFAULT_AUTO_CONNECT = false;
  22. const DEFAULT_RESET_SESSION = true;
  23. const DEFAULT_TARGET_GRAPH = 'db1';
  24. const DEFAULT_E2E_PASSWORD = '12345';
  25. const DEFAULT_SWITCH_GRAPH_TIMEOUT_MS = 100000;
  26. const DEFAULT_CHROME_LAUNCH_ARGS = [
  27. '--new-window',
  28. '--no-first-run',
  29. '--no-default-browser-check',
  30. ];
  31. const RENDERER_READY_TIMEOUT_MS = 30000;
  32. const RENDERER_READY_POLL_DELAY_MS = 250;
  33. const FALLBACK_PAGE_NAME = 'op-sim-scratch';
  34. const DEFAULT_VERIFY_CHECKSUM = true;
  35. const DEFAULT_CLEANUP_TODAY_PAGE = true;
  36. const DEFAULT_CAPTURE_REPLAY = true;
  37. const DEFAULT_SYNC_SETTLE_TIMEOUT_MS = 3000;
  38. const AGENT_BROWSER_ACTION_TIMEOUT_MS = 1000000;
  39. const PROCESS_TIMEOUT_MS = 1000000;
  40. const AGENT_BROWSER_RETRY_COUNT = 2;
  41. const BOOTSTRAP_EVAL_TIMEOUT_MS = 150000;
  42. const RENDERER_EVAL_BASE_TIMEOUT_MS = 30000;
  43. const DEFAULT_ARTIFACT_BASE_DIR = path.join('tmp', 'db-sync-repro');
  44. function usage() {
  45. return [
  46. 'Usage: node scripts/sync-open-chrome-tab-simulate.cjs [options]',
  47. '',
  48. 'Options:',
  49. ` --url <url> URL to open (default: ${DEFAULT_URL})`,
  50. ` --session <name> agent-browser session name (default: ${DEFAULT_SESSION_NAME})`,
  51. ` --instances <n> Number of concurrent browser instances (default: ${DEFAULT_INSTANCES})`,
  52. ` --graph <name> Graph name to switch/download before ops (default: ${DEFAULT_TARGET_GRAPH})`,
  53. ` --e2e-password <text> Password for E2EE modal if prompted (default: ${DEFAULT_E2E_PASSWORD})`,
  54. ' --profile <name|path|auto|none> Chrome profile to reuse login state (default: auto)',
  55. ' auto = prefer Default, then logseq.com',
  56. ' none = do not pass --profile to agent-browser (isolated profile)',
  57. ' profile labels are mapped to Chrome profile names',
  58. ' --executable-path <path> Chrome executable path (default: auto-detect system Chrome)',
  59. ' --auto-connect Enable auto-connect to an already running Chrome instance',
  60. ' --no-auto-connect Disable auto-connect to a running Chrome instance',
  61. ' --no-reset-session Do not close the target agent-browser session before starting',
  62. ` --switch-timeout-ms <n> Timeout for graph switch/download bootstrap (default: ${DEFAULT_SWITCH_GRAPH_TIMEOUT_MS})`,
  63. ` --ops <n> Total operations across all instances per round (must be >= 1, default: ${DEFAULT_OPS})`,
  64. ` --op-profile <name> Operation profile: fast|full (default: ${DEFAULT_OP_PROFILE})`,
  65. ` --op-timeout-ms <n> Timeout per operation in renderer (default: ${DEFAULT_OP_TIMEOUT_MS})`,
  66. ' --seed <text|number> Deterministic seed for operation ordering/jitter',
  67. ' --replay <artifact.json> Replay a prior captured artifact run',
  68. ` --rounds <n> Number of operation rounds per instance (default: ${DEFAULT_ROUNDS})`,
  69. ` --undo-redo-delay-ms <n> Wait time after undo/redo command (default: ${DEFAULT_UNDO_REDO_DELAY_MS})`,
  70. ` --sync-settle-timeout-ms <n> Timeout waiting for local/remote tx to settle before checksum verify (default: ${DEFAULT_SYNC_SETTLE_TIMEOUT_MS})`,
  71. ' --verify-checksum Run dev checksum diagnostics after each round (default: enabled)',
  72. ' --no-verify-checksum Skip post-round checksum diagnostics',
  73. ' --capture-replay Capture initial DB + per-op tx stream for local replay (default: enabled)',
  74. ' --no-capture-replay Skip replay capture payloads',
  75. ' --cleanup-today-page Delete today page after simulation (default: enabled)',
  76. ' --no-cleanup-today-page Keep today page unchanged after simulation',
  77. ' --headless Run agent-browser in headless mode',
  78. ' --print-only Print parsed args only, do not run simulation',
  79. ' -h, --help Show this message',
  80. ].join('\n');
  81. }
  82. function parsePositiveInteger(value, flagName) {
  83. const parsed = Number.parseInt(value, 10);
  84. if (!Number.isInteger(parsed) || parsed <= 0) {
  85. throw new Error(`${flagName} must be a positive integer`);
  86. }
  87. return parsed;
  88. }
  89. function parseNonNegativeInteger(value, flagName) {
  90. const parsed = Number.parseInt(value, 10);
  91. if (!Number.isInteger(parsed) || parsed < 0) {
  92. throw new Error(`${flagName} must be a non-negative integer`);
  93. }
  94. return parsed;
  95. }
  96. function parseArgs(argv) {
  97. const result = {
  98. url: DEFAULT_URL,
  99. session: DEFAULT_SESSION_NAME,
  100. instances: DEFAULT_INSTANCES,
  101. graph: DEFAULT_TARGET_GRAPH,
  102. e2ePassword: DEFAULT_E2E_PASSWORD,
  103. profile: DEFAULT_CHROME_PROFILE,
  104. executablePath: null,
  105. autoConnect: DEFAULT_AUTO_CONNECT,
  106. resetSession: DEFAULT_RESET_SESSION,
  107. switchTimeoutMs: DEFAULT_SWITCH_GRAPH_TIMEOUT_MS,
  108. ops: DEFAULT_OPS,
  109. opProfile: DEFAULT_OP_PROFILE,
  110. opTimeoutMs: DEFAULT_OP_TIMEOUT_MS,
  111. seed: null,
  112. replay: null,
  113. rounds: DEFAULT_ROUNDS,
  114. undoRedoDelayMs: DEFAULT_UNDO_REDO_DELAY_MS,
  115. syncSettleTimeoutMs: DEFAULT_SYNC_SETTLE_TIMEOUT_MS,
  116. verifyChecksum: DEFAULT_VERIFY_CHECKSUM,
  117. captureReplay: DEFAULT_CAPTURE_REPLAY,
  118. cleanupTodayPage: DEFAULT_CLEANUP_TODAY_PAGE,
  119. headed: DEFAULT_HEADED,
  120. printOnly: false,
  121. };
  122. for (let i = 0; i < argv.length; i += 1) {
  123. const arg = argv[i];
  124. if (arg === '--help' || arg === '-h') {
  125. return { ...result, help: true };
  126. }
  127. if (arg === '--print-only') {
  128. result.printOnly = true;
  129. continue;
  130. }
  131. if (arg === '--headless') {
  132. result.headed = false;
  133. continue;
  134. }
  135. if (arg === '--verify-checksum') {
  136. result.verifyChecksum = true;
  137. continue;
  138. }
  139. if (arg === '--no-verify-checksum') {
  140. result.verifyChecksum = false;
  141. continue;
  142. }
  143. if (arg === '--cleanup-today-page') {
  144. result.cleanupTodayPage = true;
  145. continue;
  146. }
  147. if (arg === '--no-cleanup-today-page') {
  148. result.cleanupTodayPage = false;
  149. continue;
  150. }
  151. if (arg === '--capture-replay') {
  152. result.captureReplay = true;
  153. continue;
  154. }
  155. if (arg === '--no-capture-replay') {
  156. result.captureReplay = false;
  157. continue;
  158. }
  159. if (arg === '--no-auto-connect') {
  160. result.autoConnect = false;
  161. continue;
  162. }
  163. if (arg === '--auto-connect') {
  164. result.autoConnect = true;
  165. continue;
  166. }
  167. if (arg === '--no-reset-session') {
  168. result.resetSession = false;
  169. continue;
  170. }
  171. const next = argv[i + 1];
  172. if (arg === '--url') {
  173. if (typeof next !== 'string' || next.length === 0) {
  174. throw new Error('--url must be a non-empty string');
  175. }
  176. result.url = next;
  177. i += 1;
  178. continue;
  179. }
  180. if (arg === '--session') {
  181. if (typeof next !== 'string' || next.length === 0) {
  182. throw new Error('--session must be a non-empty string');
  183. }
  184. result.session = next;
  185. i += 1;
  186. continue;
  187. }
  188. if (arg === '--graph') {
  189. if (typeof next !== 'string' || next.length === 0) {
  190. throw new Error('--graph must be a non-empty string');
  191. }
  192. result.graph = next;
  193. i += 1;
  194. continue;
  195. }
  196. if (arg === '--e2e-password') {
  197. if (typeof next !== 'string' || next.length === 0) {
  198. throw new Error('--e2e-password must be a non-empty string');
  199. }
  200. result.e2ePassword = next;
  201. i += 1;
  202. continue;
  203. }
  204. if (arg === '--instances') {
  205. result.instances = parsePositiveInteger(next, '--instances');
  206. i += 1;
  207. continue;
  208. }
  209. if (arg === '--profile') {
  210. if (typeof next !== 'string' || next.length === 0) {
  211. throw new Error('--profile must be a non-empty string');
  212. }
  213. result.profile = next;
  214. i += 1;
  215. continue;
  216. }
  217. if (arg === '--executable-path') {
  218. if (typeof next !== 'string' || next.length === 0) {
  219. throw new Error('--executable-path must be a non-empty string');
  220. }
  221. result.executablePath = next;
  222. i += 1;
  223. continue;
  224. }
  225. if (arg === '--ops') {
  226. result.ops = parsePositiveInteger(next, '--ops');
  227. i += 1;
  228. continue;
  229. }
  230. if (arg === '--op-profile') {
  231. if (typeof next !== 'string' || next.length === 0) {
  232. throw new Error('--op-profile must be a non-empty string');
  233. }
  234. const normalized = next.toLowerCase();
  235. if (normalized !== 'fast' && normalized !== 'full') {
  236. throw new Error('--op-profile must be one of: fast, full');
  237. }
  238. result.opProfile = normalized;
  239. i += 1;
  240. continue;
  241. }
  242. if (arg === '--op-timeout-ms') {
  243. result.opTimeoutMs = parsePositiveInteger(next, '--op-timeout-ms');
  244. i += 1;
  245. continue;
  246. }
  247. if (arg === '--seed') {
  248. if (typeof next !== 'string' || next.length === 0) {
  249. throw new Error('--seed must be a non-empty string');
  250. }
  251. result.seed = next;
  252. i += 1;
  253. continue;
  254. }
  255. if (arg === '--replay') {
  256. if (typeof next !== 'string' || next.length === 0) {
  257. throw new Error('--replay must be a non-empty path');
  258. }
  259. result.replay = next;
  260. i += 1;
  261. continue;
  262. }
  263. if (arg === '--rounds') {
  264. result.rounds = parsePositiveInteger(next, '--rounds');
  265. i += 1;
  266. continue;
  267. }
  268. if (arg === '--undo-redo-delay-ms') {
  269. result.undoRedoDelayMs = parseNonNegativeInteger(next, '--undo-redo-delay-ms');
  270. i += 1;
  271. continue;
  272. }
  273. if (arg === '--sync-settle-timeout-ms') {
  274. result.syncSettleTimeoutMs = parsePositiveInteger(next, '--sync-settle-timeout-ms');
  275. i += 1;
  276. continue;
  277. }
  278. if (arg === '--switch-timeout-ms') {
  279. result.switchTimeoutMs = parsePositiveInteger(next, '--switch-timeout-ms');
  280. i += 1;
  281. continue;
  282. }
  283. throw new Error(`Unknown argument: ${arg}`);
  284. }
  285. if (result.ops < 1) {
  286. throw new Error('--ops must be at least 1');
  287. }
  288. if (result.rounds < 1) {
  289. throw new Error('--rounds must be at least 1');
  290. }
  291. return result;
  292. }
  293. function spawnAndCapture(cmd, args, options = {}) {
  294. const {
  295. input,
  296. timeoutMs = PROCESS_TIMEOUT_MS,
  297. env = process.env,
  298. } = options;
  299. return new Promise((resolve, reject) => {
  300. const child = spawn(cmd, args, {
  301. stdio: ['pipe', 'pipe', 'pipe'],
  302. env,
  303. });
  304. let stdout = '';
  305. let stderr = '';
  306. let timedOut = false;
  307. const timer = setTimeout(() => {
  308. timedOut = true;
  309. child.kill('SIGTERM');
  310. }, timeoutMs);
  311. child.stdout.on('data', (payload) => {
  312. stdout += payload.toString();
  313. });
  314. child.stderr.on('data', (payload) => {
  315. stderr += payload.toString();
  316. });
  317. child.once('error', (error) => {
  318. clearTimeout(timer);
  319. reject(error);
  320. });
  321. child.once('exit', (code) => {
  322. clearTimeout(timer);
  323. if (timedOut) {
  324. reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`));
  325. return;
  326. }
  327. if (code === 0) {
  328. resolve({ code, stdout, stderr });
  329. return;
  330. }
  331. const detail = stderr.trim() || stdout.trim();
  332. reject(
  333. new Error(
  334. `Command failed: ${cmd} ${args.join(' ')} (exit ${code})` +
  335. (detail ? `\n${detail}` : '')
  336. )
  337. );
  338. });
  339. if (typeof input === 'string') {
  340. child.stdin.write(input);
  341. }
  342. child.stdin.end();
  343. });
  344. }
  345. function parseJsonOutput(output) {
  346. const text = output.trim();
  347. if (!text) {
  348. throw new Error('Expected JSON output from agent-browser but got empty output');
  349. }
  350. try {
  351. return JSON.parse(text);
  352. } catch (_error) {
  353. const lines = text.split(/\r?\n/).filter(Boolean);
  354. const lastLine = lines[lines.length - 1];
  355. try {
  356. return JSON.parse(lastLine);
  357. } catch (error) {
  358. throw new Error('Failed to parse JSON output from agent-browser: ' + String(error.message || error));
  359. }
  360. }
  361. }
  362. function sleep(ms) {
  363. return new Promise((resolve) => setTimeout(resolve, ms));
  364. }
  365. function hashSeed(input) {
  366. const text = String(input ?? '');
  367. let hash = 2166136261;
  368. for (let i = 0; i < text.length; i += 1) {
  369. hash ^= text.charCodeAt(i);
  370. hash = Math.imul(hash, 16777619);
  371. }
  372. return hash >>> 0;
  373. }
  374. function createSeededRng(seedInput) {
  375. let state = hashSeed(seedInput);
  376. if (state === 0) {
  377. state = 0x9e3779b9;
  378. }
  379. return () => {
  380. state = (state + 0x6D2B79F5) >>> 0;
  381. let payload = state;
  382. payload = Math.imul(payload ^ (payload >>> 15), payload | 1);
  383. payload ^= payload + Math.imul(payload ^ (payload >>> 7), payload | 61);
  384. return ((payload ^ (payload >>> 14)) >>> 0) / 4294967296;
  385. };
  386. }
  387. function deriveSeed(baseSeed, ...parts) {
  388. return hashSeed([String(baseSeed ?? ''), ...parts.map((it) => String(it))].join('::'));
  389. }
  390. function sanitizeForFilename(value) {
  391. return String(value || 'default').replace(/[^a-zA-Z0-9._-]+/g, '-');
  392. }
  393. async function pathExists(targetPath) {
  394. try {
  395. await fsPromises.access(targetPath);
  396. return true;
  397. } catch (_error) {
  398. return false;
  399. }
  400. }
  401. async function copyIfExists(sourcePath, destPath) {
  402. if (!(await pathExists(sourcePath))) return false;
  403. await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
  404. await fsPromises.cp(sourcePath, destPath, {
  405. force: true,
  406. recursive: true,
  407. });
  408. return true;
  409. }
  410. async function detectChromeUserDataRoot() {
  411. const home = os.homedir();
  412. const candidates = [];
  413. if (process.platform === 'darwin') {
  414. candidates.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'));
  415. } else if (process.platform === 'win32') {
  416. const localAppData = process.env.LOCALAPPDATA;
  417. if (localAppData) {
  418. candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data'));
  419. }
  420. } else {
  421. candidates.push(path.join(home, '.config', 'google-chrome'));
  422. candidates.push(path.join(home, '.config', 'chromium'));
  423. }
  424. for (const candidate of candidates) {
  425. if (await pathExists(candidate)) return candidate;
  426. }
  427. return null;
  428. }
  429. async function createIsolatedChromeUserDataDir(sourceProfileName, instanceIndex) {
  430. const sourceRoot = await detectChromeUserDataRoot();
  431. if (!sourceRoot) {
  432. throw new Error('Cannot find Chrome user data root to clone auth profile');
  433. }
  434. const sourceProfileDir = path.join(sourceRoot, sourceProfileName);
  435. if (!(await pathExists(sourceProfileDir))) {
  436. throw new Error(`Cannot find Chrome profile directory to clone: ${sourceProfileDir}`);
  437. }
  438. const targetRoot = path.join(
  439. os.tmpdir(),
  440. `logseq-op-sim-user-data-${sanitizeForFilename(sourceProfileName)}-${instanceIndex}`
  441. );
  442. const targetDefaultProfileDir = path.join(targetRoot, 'Default');
  443. await fsPromises.rm(targetRoot, { recursive: true, force: true });
  444. await fsPromises.mkdir(targetDefaultProfileDir, { recursive: true });
  445. await copyIfExists(path.join(sourceRoot, 'Local State'), path.join(targetRoot, 'Local State'));
  446. const entries = [
  447. 'Network',
  448. 'Cookies',
  449. 'Local Storage',
  450. 'Session Storage',
  451. 'IndexedDB',
  452. 'WebStorage',
  453. 'Preferences',
  454. 'Secure Preferences',
  455. ];
  456. for (const entry of entries) {
  457. await copyIfExists(
  458. path.join(sourceProfileDir, entry),
  459. path.join(targetDefaultProfileDir, entry)
  460. );
  461. }
  462. return targetRoot;
  463. }
  464. function buildChromeLaunchArgs(url) {
  465. return [
  466. `--app=${url}`,
  467. ...DEFAULT_CHROME_LAUNCH_ARGS,
  468. ];
  469. }
  470. function isRetryableAgentBrowserError(error) {
  471. const message = String(error?.message || error || '');
  472. return (
  473. /daemon may be busy or unresponsive/i.test(message) ||
  474. /resource temporarily unavailable/i.test(message) ||
  475. /os error 35/i.test(message) ||
  476. /EAGAIN/i.test(message) ||
  477. /inspected target navigated or closed/i.test(message) ||
  478. /execution context was destroyed/i.test(message) ||
  479. /cannot find context with specified id/i.test(message) ||
  480. /target closed/i.test(message) ||
  481. /session closed/i.test(message) ||
  482. /cdp command timed out/i.test(message) ||
  483. /cdp response channel closed/i.test(message) ||
  484. /operation timed out\. the page may still be loading/i.test(message)
  485. );
  486. }
  487. async function listChromeProfiles() {
  488. try {
  489. const { stdout } = await spawnAndCapture('agent-browser', ['profiles']);
  490. const lines = stdout.split(/\r?\n/);
  491. const profiles = [];
  492. for (const line of lines) {
  493. const match = line.match(/^\s+(.+?)\s+\((.+?)\)\s*$/);
  494. if (!match) continue;
  495. profiles.push({
  496. profile: match[1].trim(),
  497. label: match[2].trim(),
  498. });
  499. }
  500. return profiles;
  501. } catch (_error) {
  502. return [];
  503. }
  504. }
  505. async function detectChromeProfile() {
  506. const profiles = await listChromeProfiles();
  507. if (profiles.length > 0) {
  508. const defaultProfile = profiles.find((item) => item.profile === 'Default');
  509. if (defaultProfile) return defaultProfile.profile;
  510. return profiles[0].profile;
  511. }
  512. return 'Default';
  513. }
  514. async function detectChromeExecutablePath() {
  515. const candidates = [
  516. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  517. `${process.env.HOME || ''}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
  518. '/usr/bin/google-chrome',
  519. '/usr/bin/google-chrome-stable',
  520. '/usr/bin/chromium',
  521. '/usr/bin/chromium-browser',
  522. ].filter(Boolean);
  523. for (const candidate of candidates) {
  524. try {
  525. await fsPromises.access(candidate, fs.constants.X_OK);
  526. return candidate;
  527. } catch (_error) {
  528. // keep trying
  529. }
  530. }
  531. return null;
  532. }
  533. function expandHome(inputPath) {
  534. if (typeof inputPath !== 'string') return inputPath;
  535. if (!inputPath.startsWith('~')) return inputPath;
  536. return path.join(os.homedir(), inputPath.slice(1));
  537. }
  538. function looksLikePath(value) {
  539. return value.includes('/') || value.includes('\\') || value.startsWith('~') || value.startsWith('.');
  540. }
  541. async function resolveProfileArgument(profile) {
  542. if (!profile) return null;
  543. if (looksLikePath(profile)) {
  544. return expandHome(profile);
  545. }
  546. let profileName = profile;
  547. const profiles = await listChromeProfiles();
  548. if (profiles.length > 0) {
  549. const byLabel = profiles.find((item) => item.label.toLowerCase() === profile.toLowerCase());
  550. if (byLabel) {
  551. profileName = byLabel.profile;
  552. }
  553. }
  554. return profileName;
  555. }
  556. async function runAgentBrowser(session, commandArgs, options = {}) {
  557. const {
  558. retries = AGENT_BROWSER_RETRY_COUNT,
  559. ...commandOptions
  560. } = options;
  561. const env = {
  562. ...process.env,
  563. AGENT_BROWSER_DEFAULT_TIMEOUT: String(AGENT_BROWSER_ACTION_TIMEOUT_MS),
  564. };
  565. const globalFlags = ['--session', session];
  566. if (commandOptions.headed) {
  567. globalFlags.push('--headed');
  568. }
  569. if (commandOptions.autoConnect) {
  570. globalFlags.push('--auto-connect');
  571. }
  572. if (commandOptions.profile) {
  573. globalFlags.push('--profile', commandOptions.profile);
  574. }
  575. if (commandOptions.state) {
  576. globalFlags.push('--state', commandOptions.state);
  577. }
  578. if (Array.isArray(commandOptions.launchArgs) && commandOptions.launchArgs.length > 0) {
  579. globalFlags.push('--args', commandOptions.launchArgs.join(','));
  580. }
  581. if (commandOptions.executablePath) {
  582. globalFlags.push('--executable-path', commandOptions.executablePath);
  583. }
  584. let lastError = null;
  585. for (let attempt = 0; attempt <= retries; attempt += 1) {
  586. try {
  587. const { stdout, stderr } = await spawnAndCapture(
  588. 'agent-browser',
  589. [...globalFlags, ...commandArgs, '--json'],
  590. {
  591. ...commandOptions,
  592. env,
  593. }
  594. );
  595. const parsed = parseJsonOutput(stdout);
  596. if (!parsed || parsed.success !== true) {
  597. const fallback =
  598. String(parsed?.error || '').trim() ||
  599. stderr.trim() ||
  600. stdout.trim();
  601. throw new Error('agent-browser command failed: ' + (fallback || 'unknown error'));
  602. }
  603. return parsed;
  604. } catch (error) {
  605. lastError = error;
  606. if (attempt >= retries || !isRetryableAgentBrowserError(error)) {
  607. throw error;
  608. }
  609. await sleep((attempt + 1) * 250);
  610. }
  611. }
  612. throw lastError || new Error('agent-browser command failed');
  613. }
  614. function urlMatchesTarget(candidate, targetUrl) {
  615. if (typeof candidate !== 'string' || typeof targetUrl !== 'string') return false;
  616. if (candidate === targetUrl) return true;
  617. if (candidate.startsWith(targetUrl)) return true;
  618. try {
  619. const candidateUrl = new URL(candidate);
  620. const target = new URL(targetUrl);
  621. return (
  622. candidateUrl.origin === target.origin &&
  623. candidateUrl.pathname === target.pathname
  624. );
  625. } catch (_error) {
  626. return false;
  627. }
  628. }
  629. async function ensureActiveTabOnTargetUrl(session, targetUrl, runOptions) {
  630. const currentUrlResult = await runAgentBrowser(session, ['get', 'url'], runOptions);
  631. const currentUrl = currentUrlResult?.data?.url;
  632. if (urlMatchesTarget(currentUrl, targetUrl)) {
  633. return;
  634. }
  635. const tabList = await runAgentBrowser(session, ['tab', 'list'], runOptions);
  636. const tabs = Array.isArray(tabList?.data?.tabs) ? tabList.data.tabs : [];
  637. const matchedTab = tabs.find((tab) => urlMatchesTarget(tab?.url, targetUrl));
  638. if (matchedTab && Number.isInteger(matchedTab.index)) {
  639. await runAgentBrowser(session, ['tab', String(matchedTab.index)], runOptions);
  640. return;
  641. }
  642. const created = await runAgentBrowser(session, ['tab', 'new', targetUrl], runOptions);
  643. const createdIndex = created?.data?.index;
  644. if (Number.isInteger(createdIndex)) {
  645. await runAgentBrowser(session, ['tab', String(createdIndex)], runOptions);
  646. }
  647. }
  648. function buildRendererProgram(config) {
  649. return `(() => (async () => {
  650. const config = ${JSON.stringify(config)};
  651. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  652. const createSeededRng = (seedInput) => {
  653. const text = String(seedInput ?? '');
  654. let hash = 2166136261;
  655. for (let i = 0; i < text.length; i += 1) {
  656. hash ^= text.charCodeAt(i);
  657. hash = Math.imul(hash, 16777619);
  658. }
  659. let state = hash >>> 0;
  660. if (state === 0) state = 0x9e3779b9;
  661. return () => {
  662. state = (state + 0x6D2B79F5) >>> 0;
  663. let payload = state;
  664. payload = Math.imul(payload ^ (payload >>> 15), payload | 1);
  665. payload ^= payload + Math.imul(payload ^ (payload >>> 7), payload | 61);
  666. return ((payload ^ (payload >>> 14)) >>> 0) / 4294967296;
  667. };
  668. };
  669. const nextRandom = createSeededRng(config.seed);
  670. const randomItem = (items) => items[Math.floor(nextRandom() * items.length)];
  671. const shuffle = (items) => {
  672. const arr = Array.isArray(items) ? [...items] : [];
  673. for (let i = arr.length - 1; i > 0; i -= 1) {
  674. const j = Math.floor(nextRandom() * (i + 1));
  675. const tmp = arr[i];
  676. arr[i] = arr[j];
  677. arr[j] = tmp;
  678. }
  679. return arr;
  680. };
  681. const describeError = (error) => String(error?.message || error);
  682. const asPageName = (pageLike) => {
  683. if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
  684. if (!pageLike || typeof pageLike !== 'object') return null;
  685. if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
  686. if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
  687. if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
  688. return null;
  689. };
  690. const waitForEditorReady = async () => {
  691. const deadline = Date.now() + config.readyTimeoutMs;
  692. let lastError = null;
  693. while (Date.now() < deadline) {
  694. try {
  695. if (
  696. globalThis.logseq?.api &&
  697. typeof logseq.api.get_current_block === 'function' &&
  698. (
  699. typeof logseq.api.get_current_page === 'function' ||
  700. typeof logseq.api.get_today_page === 'function'
  701. ) &&
  702. typeof logseq.api.append_block_in_page === 'function'
  703. ) {
  704. return;
  705. }
  706. } catch (error) {
  707. lastError = error;
  708. }
  709. await sleep(config.readyPollDelayMs);
  710. }
  711. if (lastError) {
  712. throw new Error('Logseq editor readiness timed out: ' + describeError(lastError));
  713. }
  714. throw new Error('Logseq editor readiness timed out: logseq.api is unavailable');
  715. };
  716. const runPrefix =
  717. typeof config.runPrefix === 'string' && config.runPrefix.length > 0
  718. ? config.runPrefix
  719. : config.markerPrefix;
  720. const checksumWarningToken = 'db-sync/checksum-mismatch';
  721. const txRejectedWarningToken = 'db-sync/tx-rejected';
  722. const missingEntityWarningToken = 'nothing found for entity id';
  723. const applyRemoteTxWarningToken = 'frontend.worker.sync.handle-message/apply-remote-tx';
  724. const numericEntityIdWarningToken = 'non-transact outliner ops contain numeric entity ids';
  725. const checksumWarningTokenLower = checksumWarningToken.toLowerCase();
  726. const txRejectedWarningTokenLower = txRejectedWarningToken.toLowerCase();
  727. const missingEntityWarningTokenLower = missingEntityWarningToken.toLowerCase();
  728. const applyRemoteTxWarningTokenLower = applyRemoteTxWarningToken.toLowerCase();
  729. const numericEntityIdWarningTokenLower = numericEntityIdWarningToken.toLowerCase();
  730. const fatalWarningStateKey = '__logseqOpFatalWarnings';
  731. const fatalWarningPatchKey = '__logseqOpFatalWarningPatchInstalled';
  732. const consoleCaptureStateKey = '__logseqOpConsoleCaptureStore';
  733. const wsCaptureStateKey = '__logseqOpWsCaptureStore';
  734. const wsCapturePatchKey = '__logseqOpWsCapturePatchInstalled';
  735. const MAX_DIAGNOSTIC_EVENTS = 3000;
  736. const runStartedAtMs = Date.now();
  737. const chooseRunnableOperation = (requestedOperation, operableCount) => {
  738. if (
  739. requestedOperation === 'move' ||
  740. requestedOperation === 'delete' ||
  741. requestedOperation === 'indent'
  742. ) {
  743. return operableCount >= 2 ? requestedOperation : 'add';
  744. }
  745. if (
  746. requestedOperation === 'copyPaste' ||
  747. requestedOperation === 'copyPasteTreeToEmptyTarget'
  748. ) {
  749. return operableCount >= 1 ? requestedOperation : 'add';
  750. }
  751. return requestedOperation;
  752. };
  753. const stringifyConsoleArg = (value) => {
  754. if (typeof value === 'string') return value;
  755. try {
  756. return JSON.stringify(value);
  757. } catch (_error) {
  758. return String(value);
  759. }
  760. };
  761. const pushBounded = (target, value, max = MAX_DIAGNOSTIC_EVENTS) => {
  762. if (!Array.isArray(target)) return;
  763. target.push(value);
  764. if (target.length > max) {
  765. target.splice(0, target.length - max);
  766. }
  767. };
  768. const consoleCaptureStore =
  769. window[consoleCaptureStateKey] && typeof window[consoleCaptureStateKey] === 'object'
  770. ? window[consoleCaptureStateKey]
  771. : {};
  772. window[consoleCaptureStateKey] = consoleCaptureStore;
  773. const consoleCaptureEntry = Array.isArray(consoleCaptureStore[config.markerPrefix])
  774. ? consoleCaptureStore[config.markerPrefix]
  775. : [];
  776. consoleCaptureStore[config.markerPrefix] = consoleCaptureEntry;
  777. const wsCaptureStore =
  778. window[wsCaptureStateKey] && typeof window[wsCaptureStateKey] === 'object'
  779. ? window[wsCaptureStateKey]
  780. : {};
  781. window[wsCaptureStateKey] = wsCaptureStore;
  782. const wsCaptureEntry =
  783. wsCaptureStore[config.markerPrefix] && typeof wsCaptureStore[config.markerPrefix] === 'object'
  784. ? wsCaptureStore[config.markerPrefix]
  785. : { outbound: [], inbound: [], installed: false, installReason: null };
  786. wsCaptureStore[config.markerPrefix] = wsCaptureEntry;
  787. const installFatalWarningTrap = () => {
  788. const warningList = Array.isArray(window[fatalWarningStateKey])
  789. ? window[fatalWarningStateKey]
  790. : [];
  791. window[fatalWarningStateKey] = warningList;
  792. if (window[fatalWarningPatchKey]) return;
  793. window[fatalWarningPatchKey] = true;
  794. const trapMethod = (method) => {
  795. const original = console[method];
  796. if (typeof original !== 'function') return;
  797. console[method] = (...args) => {
  798. try {
  799. const text = args.map(stringifyConsoleArg).join(' ');
  800. pushBounded(consoleCaptureEntry, {
  801. level: method,
  802. text,
  803. createdAt: Date.now(),
  804. });
  805. const textLower = text.toLowerCase();
  806. if (
  807. textLower.includes(checksumWarningTokenLower) ||
  808. textLower.includes(txRejectedWarningTokenLower) ||
  809. textLower.includes(numericEntityIdWarningTokenLower) ||
  810. (
  811. textLower.includes(missingEntityWarningTokenLower) &&
  812. textLower.includes(applyRemoteTxWarningTokenLower)
  813. )
  814. ) {
  815. const kind = textLower.includes(checksumWarningTokenLower)
  816. ? 'checksum_mismatch'
  817. : (
  818. textLower.includes(txRejectedWarningTokenLower)
  819. ? 'tx_rejected'
  820. : (
  821. textLower.includes(numericEntityIdWarningTokenLower)
  822. ? 'numeric_entity_id_in_non_transact_op'
  823. : 'missing_entity_id'
  824. )
  825. );
  826. warningList.push({
  827. kind,
  828. level: method,
  829. text,
  830. createdAt: Date.now(),
  831. });
  832. }
  833. } catch (_error) {
  834. // noop
  835. }
  836. return original.apply(console, args);
  837. };
  838. };
  839. trapMethod('warn');
  840. trapMethod('error');
  841. trapMethod('log');
  842. };
  843. const toWsText = (value) => {
  844. if (typeof value === 'string') return value.slice(0, 4000);
  845. if (value instanceof ArrayBuffer) {
  846. return '[ArrayBuffer byteLength=' + value.byteLength + ']';
  847. }
  848. if (typeof Blob !== 'undefined' && value instanceof Blob) {
  849. return '[Blob size=' + value.size + ']';
  850. }
  851. try {
  852. return JSON.stringify(value).slice(0, 4000);
  853. } catch (_error) {
  854. return String(value).slice(0, 4000);
  855. }
  856. };
  857. const installWsCapture = () => {
  858. try {
  859. if (!globalThis.WebSocket) {
  860. wsCaptureEntry.installed = false;
  861. wsCaptureEntry.installReason = 'WebSocket unavailable';
  862. return;
  863. }
  864. if (window[wsCapturePatchKey] !== true) {
  865. const OriginalWebSocket = window.WebSocket;
  866. const originalSend = OriginalWebSocket.prototype.send;
  867. OriginalWebSocket.prototype.send = function patchedSend(payload) {
  868. try {
  869. pushBounded(wsCaptureEntry.outbound, {
  870. createdAt: Date.now(),
  871. url: typeof this?.url === 'string' ? this.url : null,
  872. readyState: Number.isInteger(this?.readyState) ? this.readyState : null,
  873. payload: toWsText(payload),
  874. });
  875. } catch (_error) {
  876. // noop
  877. }
  878. return originalSend.call(this, payload);
  879. };
  880. window.WebSocket = function LogseqWsCapture(...args) {
  881. const ws = new OriginalWebSocket(...args);
  882. try {
  883. ws.addEventListener('message', (event) => {
  884. try {
  885. pushBounded(wsCaptureEntry.inbound, {
  886. createdAt: Date.now(),
  887. url: typeof ws?.url === 'string' ? ws.url : null,
  888. readyState: Number.isInteger(ws?.readyState) ? ws.readyState : null,
  889. payload: toWsText(event?.data),
  890. });
  891. } catch (_error) {
  892. // noop
  893. }
  894. });
  895. } catch (_error) {
  896. // noop
  897. }
  898. return ws;
  899. };
  900. window.WebSocket.prototype = OriginalWebSocket.prototype;
  901. Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);
  902. for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
  903. window.WebSocket[key] = OriginalWebSocket[key];
  904. }
  905. window[wsCapturePatchKey] = true;
  906. }
  907. wsCaptureEntry.installed = true;
  908. wsCaptureEntry.installReason = null;
  909. } catch (error) {
  910. wsCaptureEntry.installed = false;
  911. wsCaptureEntry.installReason = describeError(error);
  912. }
  913. };
  914. const latestFatalWarning = () => {
  915. const warningList = Array.isArray(window[fatalWarningStateKey])
  916. ? window[fatalWarningStateKey]
  917. : [];
  918. return warningList.length > 0 ? warningList[warningList.length - 1] : null;
  919. };
  920. const parseCreatedAtMs = (value) => {
  921. if (value == null) return null;
  922. if (typeof value === 'number' && Number.isFinite(value)) return value;
  923. if (value instanceof Date) {
  924. const ms = value.getTime();
  925. return Number.isFinite(ms) ? ms : null;
  926. }
  927. const ms = new Date(value).getTime();
  928. return Number.isFinite(ms) ? ms : null;
  929. };
  930. const getRtcLogList = () => {
  931. try {
  932. if (!globalThis.logseq?.api?.get_state_from_store) return [];
  933. const logs = logseq.api.get_state_from_store(['rtc/logs']);
  934. if (Array.isArray(logs) && logs.length > 0) return logs;
  935. const latest = logseq.api.get_state_from_store(['rtc/log']);
  936. return latest && typeof latest === 'object' ? [latest] : [];
  937. } catch (_error) {
  938. return [];
  939. }
  940. };
  941. const latestChecksumMismatchRtcLog = () => {
  942. try {
  943. const logs = getRtcLogList();
  944. for (let i = logs.length - 1; i >= 0; i -= 1) {
  945. const entry = logs[i];
  946. if (!entry || typeof entry !== 'object') continue;
  947. const createdAtMs = parseCreatedAtMs(entry['created-at'] || entry.createdAt);
  948. if (Number.isFinite(createdAtMs) && createdAtMs < runStartedAtMs) continue;
  949. const type = String(entry.type || '').toLowerCase();
  950. const localChecksum = String(
  951. entry['local-checksum'] || entry.localChecksum || entry.local_checksum || ''
  952. );
  953. const remoteChecksum = String(
  954. entry['remote-checksum'] || entry.remoteChecksum || entry.remote_checksum || ''
  955. );
  956. const hasMismatchType = type.includes('checksum-mismatch');
  957. const hasDifferentChecksums =
  958. localChecksum.length > 0 &&
  959. remoteChecksum.length > 0 &&
  960. localChecksum !== remoteChecksum;
  961. if (!hasMismatchType && !hasDifferentChecksums) continue;
  962. return {
  963. type: entry.type || null,
  964. messageType: entry['message-type'] || entry.messageType || null,
  965. localTx: entry['local-tx'] || entry.localTx || null,
  966. remoteTx: entry['remote-tx'] || entry.remoteTx || null,
  967. localChecksum,
  968. remoteChecksum,
  969. createdAt: entry['created-at'] || entry.createdAt || null,
  970. raw: entry,
  971. };
  972. }
  973. return null;
  974. } catch (_error) {
  975. return null;
  976. }
  977. };
  978. const latestTxRejectedRtcLog = () => {
  979. try {
  980. const logs = getRtcLogList();
  981. for (let i = logs.length - 1; i >= 0; i -= 1) {
  982. const entry = logs[i];
  983. if (!entry || typeof entry !== 'object') continue;
  984. const createdAtMs = parseCreatedAtMs(entry['created-at'] || entry.createdAt);
  985. if (Number.isFinite(createdAtMs) && createdAtMs < runStartedAtMs) continue;
  986. const type = String(entry.type || '').toLowerCase();
  987. if (!type.includes('tx-rejected')) continue;
  988. return {
  989. type: entry.type || null,
  990. messageType: entry['message-type'] || entry.messageType || null,
  991. reason: entry.reason || null,
  992. remoteTx: entry['t'] || entry.t || null,
  993. createdAt: entry['created-at'] || entry.createdAt || null,
  994. raw: entry,
  995. };
  996. }
  997. return null;
  998. } catch (_error) {
  999. return null;
  1000. }
  1001. };
  1002. const failIfFatalSignalSeen = () => {
  1003. const txRejectedRtcLog = latestTxRejectedRtcLog();
  1004. if (txRejectedRtcLog) {
  1005. throw new Error('tx rejected rtc-log detected: ' + JSON.stringify(txRejectedRtcLog));
  1006. }
  1007. const warning = latestFatalWarning();
  1008. if (!warning) return;
  1009. const details = String(warning.text || '').slice(0, 500);
  1010. if (warning.kind === 'tx_rejected') {
  1011. throw new Error('tx-rejected warning detected: ' + details);
  1012. }
  1013. if (warning.kind === 'missing_entity_id') {
  1014. throw new Error('missing-entity-id warning detected: ' + details);
  1015. }
  1016. if (warning.kind === 'numeric_entity_id_in_non_transact_op') {
  1017. throw new Error('numeric-entity-id-in-non-transact-op warning detected: ' + details);
  1018. }
  1019. // checksum mismatch is recorded for diagnostics but is non-fatal in simulation.
  1020. };
  1021. const clearFatalSignalState = () => {
  1022. try {
  1023. const warningList = Array.isArray(window[fatalWarningStateKey])
  1024. ? window[fatalWarningStateKey]
  1025. : null;
  1026. if (warningList) {
  1027. warningList.length = 0;
  1028. }
  1029. } catch (_error) {
  1030. // noop
  1031. }
  1032. try {
  1033. if (Array.isArray(consoleCaptureEntry)) {
  1034. consoleCaptureEntry.length = 0;
  1035. }
  1036. } catch (_error) {
  1037. // noop
  1038. }
  1039. try {
  1040. if (Array.isArray(wsCaptureEntry?.outbound)) {
  1041. wsCaptureEntry.outbound.length = 0;
  1042. }
  1043. if (Array.isArray(wsCaptureEntry?.inbound)) {
  1044. wsCaptureEntry.inbound.length = 0;
  1045. }
  1046. } catch (_error) {
  1047. // noop
  1048. }
  1049. try {
  1050. if (globalThis.logseq?.api?.set_state_from_store) {
  1051. logseq.api.set_state_from_store(['rtc/log'], null);
  1052. logseq.api.set_state_from_store(['rtc/logs'], []);
  1053. }
  1054. } catch (_error) {
  1055. // noop
  1056. }
  1057. };
  1058. const withTimeout = async (promise, timeoutMs, label) => {
  1059. if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  1060. return promise;
  1061. }
  1062. let timer = null;
  1063. try {
  1064. return await Promise.race([
  1065. promise,
  1066. new Promise((_, reject) => {
  1067. timer = setTimeout(() => {
  1068. reject(new Error(label + ' timed out after ' + timeoutMs + 'ms'));
  1069. }, timeoutMs);
  1070. }),
  1071. ]);
  1072. } finally {
  1073. if (timer) clearTimeout(timer);
  1074. }
  1075. };
  1076. const flattenBlocks = (nodes, acc = []) => {
  1077. if (!Array.isArray(nodes)) return acc;
  1078. for (const node of nodes) {
  1079. if (!node) continue;
  1080. acc.push(node);
  1081. if (Array.isArray(node.children) && node.children.length > 0) {
  1082. flattenBlocks(node.children, acc);
  1083. }
  1084. }
  1085. return acc;
  1086. };
  1087. const isClientBlock = (block) =>
  1088. typeof block?.content === 'string' && block.content.startsWith(config.markerPrefix);
  1089. const isOperableBlock = (block) =>
  1090. typeof block?.content === 'string' && block.content.startsWith(runPrefix);
  1091. const isClientRootBlock = (block) =>
  1092. typeof block?.content === 'string' && block.content === (config.markerPrefix + ' client-root');
  1093. let operationPageName = null;
  1094. const listPageBlocks = async () => {
  1095. if (
  1096. typeof operationPageName === 'string' &&
  1097. operationPageName.length > 0 &&
  1098. typeof logseq.api.get_page_blocks_tree === 'function'
  1099. ) {
  1100. const tree = await logseq.api.get_page_blocks_tree(operationPageName);
  1101. return flattenBlocks(tree, []);
  1102. }
  1103. const tree = await logseq.api.get_current_page_blocks_tree();
  1104. return flattenBlocks(tree, []);
  1105. };
  1106. const listOperableBlocks = async () => {
  1107. const flattened = await listPageBlocks();
  1108. return flattened.filter(isOperableBlock);
  1109. };
  1110. const listManagedBlocks = async () => {
  1111. const operableBlocks = await listOperableBlocks();
  1112. return operableBlocks.filter(isClientBlock);
  1113. };
  1114. const ensureClientRootBlock = async (anchorBlock) => {
  1115. const existing = (await listOperableBlocks()).find(isClientRootBlock);
  1116. if (existing?.uuid) return existing;
  1117. const inserted = await logseq.api.insert_block(anchorBlock.uuid, config.markerPrefix + ' client-root', {
  1118. sibling: true,
  1119. before: false,
  1120. focus: false,
  1121. });
  1122. if (!inserted?.uuid) {
  1123. throw new Error('Failed to create client root block');
  1124. }
  1125. return inserted;
  1126. };
  1127. const pickIndentCandidate = async (blocks) => {
  1128. for (const candidate of shuffle(blocks)) {
  1129. const prev = await logseq.api.get_previous_sibling_block(candidate.uuid);
  1130. if (prev?.uuid) return candidate;
  1131. }
  1132. return null;
  1133. };
  1134. const pickOutdentCandidate = async (blocks) => {
  1135. for (const candidate of shuffle(blocks)) {
  1136. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  1137. const parentId = full?.parent?.id;
  1138. const pageId = full?.page?.id;
  1139. if (parentId && pageId && parentId !== pageId) {
  1140. return candidate;
  1141. }
  1142. }
  1143. return null;
  1144. };
  1145. const getPreviousSiblingUuid = async (uuid) => {
  1146. const prev = await logseq.api.get_previous_sibling_block(uuid);
  1147. return prev?.uuid || null;
  1148. };
  1149. const ensureIndentCandidate = async (blocks, anchorBlock, opIndex) => {
  1150. const existing = await pickIndentCandidate(blocks);
  1151. if (existing?.uuid) return existing;
  1152. const baseTarget = blocks.length > 0 ? randomItem(blocks) : anchorBlock;
  1153. const base = await logseq.api.insert_block(baseTarget.uuid, config.markerPrefix + ' indent-base-' + opIndex, {
  1154. sibling: true,
  1155. before: false,
  1156. focus: false,
  1157. });
  1158. if (!base?.uuid) {
  1159. throw new Error('Failed to create indent base block');
  1160. }
  1161. const candidate = await logseq.api.insert_block(base.uuid, config.markerPrefix + ' indent-candidate-' + opIndex, {
  1162. sibling: true,
  1163. before: false,
  1164. focus: false,
  1165. });
  1166. if (!candidate?.uuid) {
  1167. throw new Error('Failed to create indent candidate block');
  1168. }
  1169. return candidate;
  1170. };
  1171. const runIndent = async (candidate) => {
  1172. const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
  1173. if (!prevUuid) {
  1174. throw new Error('No previous sibling for indent candidate');
  1175. }
  1176. await logseq.api.move_block(candidate.uuid, prevUuid, {
  1177. before: false,
  1178. children: true,
  1179. });
  1180. };
  1181. const ensureOutdentCandidate = async (blocks, anchorBlock, opIndex) => {
  1182. const existing = await pickOutdentCandidate(blocks);
  1183. if (existing?.uuid) return existing;
  1184. const candidate = await ensureIndentCandidate(blocks, anchorBlock, opIndex);
  1185. await runIndent(candidate);
  1186. return candidate;
  1187. };
  1188. const runOutdent = async (candidate) => {
  1189. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  1190. const parentId = full?.parent?.id;
  1191. const pageId = full?.page?.id;
  1192. if (!parentId || !pageId || parentId === pageId) {
  1193. throw new Error('Outdent candidate is not nested');
  1194. }
  1195. const parent = await logseq.api.get_block(parentId, { includeChildren: false });
  1196. if (!parent?.uuid) {
  1197. throw new Error('Cannot resolve parent block for outdent');
  1198. }
  1199. await logseq.api.move_block(candidate.uuid, parent.uuid, {
  1200. before: false,
  1201. children: false,
  1202. });
  1203. };
  1204. const pickRandomGroup = (blocks, minSize = 1, maxSize = 3) => {
  1205. const pool = shuffle(blocks);
  1206. const lower = Math.max(1, Math.min(minSize, pool.length));
  1207. const upper = Math.max(lower, Math.min(maxSize, pool.length));
  1208. const size = lower + Math.floor(nextRandom() * (upper - lower + 1));
  1209. return pool.slice(0, size);
  1210. };
  1211. const toBatchTree = (block) => ({
  1212. content: typeof block?.content === 'string' ? block.content : '',
  1213. children: Array.isArray(block?.children) ? block.children.map(toBatchTree) : [],
  1214. });
  1215. const getAnchor = async () => {
  1216. const deadline = Date.now() + config.readyTimeoutMs;
  1217. let lastError = null;
  1218. while (Date.now() < deadline) {
  1219. try {
  1220. if (typeof logseq.api.get_today_page === 'function') {
  1221. const todayPage = await logseq.api.get_today_page();
  1222. const todayPageName = asPageName(todayPage);
  1223. if (todayPageName) {
  1224. operationPageName = todayPageName;
  1225. const seeded = await logseq.api.append_block_in_page(
  1226. todayPageName,
  1227. config.markerPrefix + ' anchor',
  1228. {}
  1229. );
  1230. if (seeded?.uuid) return seeded;
  1231. }
  1232. }
  1233. if (typeof logseq.api.get_current_page === 'function') {
  1234. const currentPage = await logseq.api.get_current_page();
  1235. const currentPageName = asPageName(currentPage);
  1236. if (currentPageName) {
  1237. operationPageName = currentPageName;
  1238. const seeded = await logseq.api.append_block_in_page(
  1239. currentPageName,
  1240. config.markerPrefix + ' anchor',
  1241. {}
  1242. );
  1243. if (seeded?.uuid) return seeded;
  1244. }
  1245. }
  1246. const currentBlock = await logseq.api.get_current_block();
  1247. if (currentBlock && currentBlock.uuid) {
  1248. return currentBlock;
  1249. }
  1250. {
  1251. operationPageName = config.fallbackPageName;
  1252. const seeded = await logseq.api.append_block_in_page(
  1253. config.fallbackPageName,
  1254. config.markerPrefix + ' anchor',
  1255. {}
  1256. );
  1257. if (seeded?.uuid) return seeded;
  1258. }
  1259. } catch (error) {
  1260. lastError = error;
  1261. }
  1262. await sleep(config.readyPollDelayMs);
  1263. }
  1264. if (lastError) {
  1265. throw new Error('Unable to resolve anchor block: ' + describeError(lastError));
  1266. }
  1267. throw new Error('Unable to resolve anchor block: open a graph and page, then retry');
  1268. };
  1269. const parseRtcTxText = (text) => {
  1270. if (typeof text !== 'string' || text.length === 0) return null;
  1271. const localMatch = text.match(/:local-tx\\s+(-?\\d+)/);
  1272. const remoteMatch = text.match(/:remote-tx\\s+(-?\\d+)/);
  1273. if (!localMatch || !remoteMatch) return null;
  1274. return {
  1275. localTx: Number.parseInt(localMatch[1], 10),
  1276. remoteTx: Number.parseInt(remoteMatch[1], 10),
  1277. };
  1278. };
  1279. const readRtcTx = () => {
  1280. const node = document.querySelector('[data-testid="rtc-tx"]');
  1281. if (!node) return null;
  1282. return parseRtcTxText((node.textContent || '').trim());
  1283. };
  1284. const waitForRtcSettle = async () => {
  1285. const deadline = Date.now() + config.syncSettleTimeoutMs;
  1286. let stableHits = 0;
  1287. let last = null;
  1288. while (Date.now() < deadline) {
  1289. const current = readRtcTx();
  1290. if (current && Number.isFinite(current.localTx) && Number.isFinite(current.remoteTx)) {
  1291. last = current;
  1292. if (current.localTx === current.remoteTx) {
  1293. stableHits += 1;
  1294. if (stableHits >= 3) return { ok: true, ...current };
  1295. } else {
  1296. stableHits = 0;
  1297. }
  1298. }
  1299. await sleep(250);
  1300. }
  1301. return { ok: false, ...(last || {}), reason: 'rtc-tx did not settle before timeout' };
  1302. };
  1303. const extractNotificationTexts = () =>
  1304. Array.from(
  1305. document.querySelectorAll('.ui__notifications-content .text-sm.leading-5.font-medium.whitespace-pre-line')
  1306. )
  1307. .map((el) => (el.textContent || '').trim())
  1308. .filter(Boolean);
  1309. const parseChecksumNotification = (text) => {
  1310. if (typeof text !== 'string' || !text.includes('Checksum recomputed.')) return null;
  1311. const match = text.match(
  1312. /Recomputed:\\s*([0-9a-fA-F]{16})\\s*,\\s*local:\\s*([^,]+)\\s*,\\s*remote:\\s*([^,\\.]+)/
  1313. );
  1314. if (!match) {
  1315. return {
  1316. raw: text,
  1317. parsed: false,
  1318. reason: 'notification did not match expected checksum format',
  1319. };
  1320. }
  1321. const normalize = (value) => {
  1322. const trimmed = String(value || '').trim();
  1323. if (trimmed === '<nil>') return null;
  1324. return trimmed;
  1325. };
  1326. const recomputed = normalize(match[1]);
  1327. const local = normalize(match[2]);
  1328. const remote = normalize(match[3]);
  1329. const localMatches = recomputed === local;
  1330. const remoteMatches = recomputed === remote;
  1331. const localRemoteMatch = local === remote;
  1332. return {
  1333. raw: text,
  1334. parsed: true,
  1335. recomputed,
  1336. local,
  1337. remote,
  1338. localMatches,
  1339. remoteMatches,
  1340. localRemoteMatch,
  1341. matched: localMatches && remoteMatches && localRemoteMatch,
  1342. };
  1343. };
  1344. const runChecksumDiagnostics = async () => {
  1345. const settle = await waitForRtcSettle();
  1346. if (!settle.ok) {
  1347. return {
  1348. ok: false,
  1349. settle,
  1350. reason: settle.reason || 'sync did not settle',
  1351. };
  1352. }
  1353. const before = new Set(extractNotificationTexts());
  1354. const commandCandidates = ['dev/recompute-checksum', ':dev/recompute-checksum'];
  1355. let invoked = null;
  1356. let invokeError = null;
  1357. for (const command of commandCandidates) {
  1358. try {
  1359. await logseq.api.invoke_external_command(command);
  1360. invoked = command;
  1361. invokeError = null;
  1362. break;
  1363. } catch (error) {
  1364. invokeError = error;
  1365. }
  1366. }
  1367. if (!invoked) {
  1368. return {
  1369. ok: false,
  1370. settle,
  1371. reason: 'failed to invoke checksum command',
  1372. error: describeError(invokeError),
  1373. };
  1374. }
  1375. const deadline = Date.now() + Math.max(10000, config.readyTimeoutMs);
  1376. let seen = null;
  1377. while (Date.now() < deadline) {
  1378. const current = extractNotificationTexts();
  1379. for (const text of current) {
  1380. if (before.has(text)) continue;
  1381. const parsed = parseChecksumNotification(text);
  1382. if (parsed) {
  1383. return {
  1384. ok: Boolean(parsed.matched),
  1385. settle,
  1386. invoked,
  1387. ...parsed,
  1388. };
  1389. }
  1390. seen = text;
  1391. }
  1392. await sleep(250);
  1393. }
  1394. return {
  1395. ok: false,
  1396. settle,
  1397. invoked,
  1398. reason: 'checksum notification not found before timeout',
  1399. seen,
  1400. };
  1401. };
  1402. const replayCaptureEnabled = config.captureReplay !== false;
  1403. const replayCaptureStoreKey = '__logseqOpReplayCaptureStore';
  1404. const replayAttrByNormalizedName = {
  1405. uuid: ':block/uuid',
  1406. title: ':block/title',
  1407. name: ':block/name',
  1408. parent: ':block/parent',
  1409. page: ':block/page',
  1410. order: ':block/order',
  1411. };
  1412. const replayCaptureState = {
  1413. installed: false,
  1414. installReason: null,
  1415. enabled: false,
  1416. currentOpIndex: null,
  1417. txLog: [],
  1418. };
  1419. const replayCaptureStoreRoot =
  1420. window[replayCaptureStoreKey] && typeof window[replayCaptureStoreKey] === 'object'
  1421. ? window[replayCaptureStoreKey]
  1422. : {};
  1423. window[replayCaptureStoreKey] = replayCaptureStoreRoot;
  1424. const replayCaptureStoreEntry =
  1425. replayCaptureStoreRoot[config.markerPrefix] &&
  1426. typeof replayCaptureStoreRoot[config.markerPrefix] === 'object'
  1427. ? replayCaptureStoreRoot[config.markerPrefix]
  1428. : {};
  1429. replayCaptureStoreEntry.markerPrefix = config.markerPrefix;
  1430. replayCaptureStoreEntry.updatedAt = Date.now();
  1431. replayCaptureStoreEntry.initialDb = null;
  1432. replayCaptureStoreEntry.opLog = [];
  1433. replayCaptureStoreEntry.txCapture = {
  1434. enabled: replayCaptureEnabled,
  1435. installed: false,
  1436. installReason: null,
  1437. totalTx: 0,
  1438. txLog: [],
  1439. };
  1440. replayCaptureStoreRoot[config.markerPrefix] = replayCaptureStoreEntry;
  1441. const readAny = (value, keys) => {
  1442. if (!value || typeof value !== 'object') return undefined;
  1443. for (const key of keys) {
  1444. if (Object.prototype.hasOwnProperty.call(value, key)) {
  1445. return value[key];
  1446. }
  1447. }
  1448. return undefined;
  1449. };
  1450. const normalizeReplayAttr = (value) => {
  1451. if (typeof value !== 'string') return null;
  1452. const text = value.trim();
  1453. if (!text) return null;
  1454. if (text.startsWith(':')) {
  1455. return text;
  1456. }
  1457. if (text.includes('/')) {
  1458. return ':' + text;
  1459. }
  1460. return replayAttrByNormalizedName[text] || null;
  1461. };
  1462. const normalizeReplayDatom = (datom) => {
  1463. if (!datom || typeof datom !== 'object') return null;
  1464. const eRaw = readAny(datom, ['e', ':e']);
  1465. const aRaw = readAny(datom, ['a', ':a']);
  1466. const vRaw = readAny(datom, ['v', ':v']);
  1467. const addedRaw = readAny(datom, ['added', ':added']);
  1468. const e = Number(eRaw);
  1469. if (!Number.isInteger(e)) return null;
  1470. const attr = normalizeReplayAttr(typeof aRaw === 'string' ? aRaw : String(aRaw || ''));
  1471. if (!attr) return null;
  1472. let v = vRaw;
  1473. if (attr === ':block/uuid' && typeof vRaw === 'string') {
  1474. v = vRaw;
  1475. } else if ((attr === ':block/parent' || attr === ':block/page') && Number.isFinite(Number(vRaw))) {
  1476. v = Number(vRaw);
  1477. }
  1478. return {
  1479. e,
  1480. a: attr,
  1481. v,
  1482. added: addedRaw !== false,
  1483. };
  1484. };
  1485. const installReplayTxCapture = () => {
  1486. if (!replayCaptureEnabled) {
  1487. replayCaptureState.installReason = 'disabled by config';
  1488. replayCaptureStoreEntry.txCapture.installReason = replayCaptureState.installReason;
  1489. return;
  1490. }
  1491. const core = window.LSPluginCore;
  1492. if (!core || typeof core.hookDb !== 'function') {
  1493. replayCaptureState.installReason = 'LSPluginCore.hookDb unavailable';
  1494. replayCaptureStoreEntry.txCapture.installReason = replayCaptureState.installReason;
  1495. return;
  1496. }
  1497. const sinkKey = '__logseqOpReplayCaptureSinks';
  1498. const patchInstalledKey = '__logseqOpReplayCapturePatchInstalled';
  1499. const sinks = Array.isArray(window[sinkKey]) ? window[sinkKey] : [];
  1500. window[sinkKey] = sinks;
  1501. const sink = (type, payload) => {
  1502. try {
  1503. if (replayCaptureState.enabled && String(type || '') === 'changed' && payload && typeof payload === 'object') {
  1504. const rawDatoms = readAny(payload, ['txData', ':tx-data', 'tx-data', 'tx_data']);
  1505. const datoms = Array.isArray(rawDatoms)
  1506. ? rawDatoms.map(normalizeReplayDatom).filter(Boolean)
  1507. : [];
  1508. if (datoms.length > 0) {
  1509. const entry = {
  1510. capturedAt: Date.now(),
  1511. opIndex: Number.isInteger(replayCaptureState.currentOpIndex)
  1512. ? replayCaptureState.currentOpIndex
  1513. : null,
  1514. datoms,
  1515. };
  1516. replayCaptureState.txLog.push(entry);
  1517. replayCaptureStoreEntry.txCapture.txLog.push(entry);
  1518. replayCaptureStoreEntry.txCapture.totalTx = replayCaptureStoreEntry.txCapture.txLog.length;
  1519. replayCaptureStoreEntry.updatedAt = Date.now();
  1520. }
  1521. }
  1522. } catch (_error) {
  1523. // keep capture best-effort
  1524. }
  1525. };
  1526. sinks.push(sink);
  1527. if (window[patchInstalledKey] !== true) {
  1528. const original = core.hookDb.bind(core);
  1529. core.hookDb = (type, payload, pluginId) => {
  1530. try {
  1531. const listeners = Array.isArray(window[sinkKey]) ? window[sinkKey] : [];
  1532. for (const listener of listeners) {
  1533. if (typeof listener === 'function') {
  1534. listener(type, payload);
  1535. }
  1536. }
  1537. } catch (_error) {
  1538. // keep hook best-effort
  1539. }
  1540. return original(type, payload, pluginId);
  1541. };
  1542. window[patchInstalledKey] = true;
  1543. }
  1544. replayCaptureState.installed = true;
  1545. replayCaptureState.enabled = true;
  1546. replayCaptureState.installReason = null;
  1547. replayCaptureStoreEntry.txCapture.installed = true;
  1548. replayCaptureStoreEntry.txCapture.enabled = true;
  1549. replayCaptureStoreEntry.txCapture.installReason = null;
  1550. replayCaptureStoreEntry.updatedAt = Date.now();
  1551. };
  1552. const flattenAnyObjects = (value, acc = []) => {
  1553. if (Array.isArray(value)) {
  1554. for (const item of value) flattenAnyObjects(item, acc);
  1555. return acc;
  1556. }
  1557. if (value && typeof value === 'object') {
  1558. acc.push(value);
  1559. }
  1560. return acc;
  1561. };
  1562. const normalizeSnapshotBlock = (block) => {
  1563. if (!block || typeof block !== 'object') return null;
  1564. const id = Number(readAny(block, ['id', 'db/id', ':db/id']));
  1565. const uuid = readAny(block, ['uuid', 'block/uuid', ':block/uuid']);
  1566. if (!Number.isInteger(id) || typeof uuid !== 'string' || uuid.length === 0) return null;
  1567. const parent = readAny(block, ['parent', 'block/parent', ':block/parent']);
  1568. const page = readAny(block, ['page', 'block/page', ':block/page']);
  1569. const parentId = Number(readAny(parent, ['id', 'db/id', ':db/id']));
  1570. const pageId = Number(readAny(page, ['id', 'db/id', ':db/id']));
  1571. const title = readAny(block, ['title', 'block/title', ':block/title']);
  1572. const name = readAny(block, ['name', 'block/name', ':block/name']);
  1573. const order = readAny(block, ['order', 'block/order', ':block/order']);
  1574. return {
  1575. id,
  1576. uuid,
  1577. parentId: Number.isInteger(parentId) ? parentId : null,
  1578. pageId: Number.isInteger(pageId) ? pageId : null,
  1579. title: typeof title === 'string' ? title : null,
  1580. name: typeof name === 'string' ? name : null,
  1581. order: typeof order === 'string' ? order : null,
  1582. };
  1583. };
  1584. const captureInitialDbSnapshot = async () => {
  1585. if (!replayCaptureEnabled) {
  1586. return {
  1587. ok: false,
  1588. reason: 'disabled by config',
  1589. blockCount: 0,
  1590. blocks: [],
  1591. };
  1592. }
  1593. if (typeof logseq.api.datascript_query !== 'function') {
  1594. return {
  1595. ok: false,
  1596. reason: 'datascript_query API unavailable',
  1597. blockCount: 0,
  1598. blocks: [],
  1599. };
  1600. }
  1601. try {
  1602. const query = '[:find (pull ?b [:db/id :block/uuid :block/title :block/name :block/order {:block/parent [:db/id :block/uuid]} {:block/page [:db/id :block/uuid]}]) :where [?b :block/uuid]]';
  1603. const raw = await logseq.api.datascript_query(query);
  1604. const objects = flattenAnyObjects(raw, []);
  1605. const blocks = objects
  1606. .map(normalizeSnapshotBlock)
  1607. .filter(Boolean);
  1608. const dedup = new Map();
  1609. for (const block of blocks) {
  1610. dedup.set(block.id, block);
  1611. }
  1612. const normalized = Array.from(dedup.values())
  1613. .sort((a, b) => a.id - b.id);
  1614. return {
  1615. ok: true,
  1616. blockCount: normalized.length,
  1617. blocks: normalized,
  1618. };
  1619. } catch (error) {
  1620. return {
  1621. ok: false,
  1622. reason: describeError(error),
  1623. blockCount: 0,
  1624. blocks: [],
  1625. };
  1626. }
  1627. };
  1628. const snapshotBlocksToStateMap = (blocks) => {
  1629. const stateMap = new Map();
  1630. if (!Array.isArray(blocks)) return stateMap;
  1631. for (const block of blocks) {
  1632. if (!block || typeof block !== 'object') continue;
  1633. const id = Number(block.id);
  1634. if (!Number.isInteger(id)) continue;
  1635. stateMap.set(id, {
  1636. id,
  1637. uuid: typeof block.uuid === 'string' ? block.uuid : null,
  1638. title: typeof block.title === 'string' ? block.title : null,
  1639. name: typeof block.name === 'string' ? block.name : null,
  1640. order: typeof block.order === 'string' ? block.order : null,
  1641. parentId: Number.isInteger(block.parentId) ? block.parentId : null,
  1642. pageId: Number.isInteger(block.pageId) ? block.pageId : null,
  1643. });
  1644. }
  1645. return stateMap;
  1646. };
  1647. const captureChecksumStateMap = async () => {
  1648. const snapshot = await captureInitialDbSnapshot();
  1649. return {
  1650. ok: snapshot.ok === true,
  1651. reason: snapshot.reason || null,
  1652. state: snapshotBlocksToStateMap(snapshot.blocks),
  1653. };
  1654. };
  1655. const replayDatomEntriesFromStateDiff = (beforeMap, afterMap) => {
  1656. const datoms = [];
  1657. const allIds = new Set();
  1658. for (const id of beforeMap.keys()) allIds.add(id);
  1659. for (const id of afterMap.keys()) allIds.add(id);
  1660. const scalarAttrs = [
  1661. ['uuid', ':block/uuid'],
  1662. ['title', ':block/title'],
  1663. ['name', ':block/name'],
  1664. ['order', ':block/order'],
  1665. ];
  1666. const refAttrs = [
  1667. ['parentId', ':block/parent'],
  1668. ['pageId', ':block/page'],
  1669. ];
  1670. for (const id of allIds) {
  1671. const before = beforeMap.get(id) || null;
  1672. const after = afterMap.get(id) || null;
  1673. for (const [key, attr] of scalarAttrs) {
  1674. const beforeValue = before ? before[key] : null;
  1675. const afterValue = after ? after[key] : null;
  1676. if (beforeValue === afterValue) continue;
  1677. if (typeof beforeValue === 'string') {
  1678. datoms.push({ e: id, a: attr, v: beforeValue, added: false });
  1679. }
  1680. if (typeof afterValue === 'string') {
  1681. datoms.push({ e: id, a: attr, v: afterValue, added: true });
  1682. }
  1683. }
  1684. for (const [key, attr] of refAttrs) {
  1685. const beforeValue = before ? before[key] : null;
  1686. const afterValue = after ? after[key] : null;
  1687. if (beforeValue === afterValue) continue;
  1688. if (Number.isInteger(beforeValue)) {
  1689. datoms.push({ e: id, a: attr, v: beforeValue, added: false });
  1690. }
  1691. if (Number.isInteger(afterValue)) {
  1692. datoms.push({ e: id, a: attr, v: afterValue, added: true });
  1693. }
  1694. }
  1695. }
  1696. return datoms;
  1697. };
  1698. const counts = {
  1699. add: 0,
  1700. delete: 0,
  1701. move: 0,
  1702. indent: 0,
  1703. outdent: 0,
  1704. undo: 0,
  1705. redo: 0,
  1706. copyPaste: 0,
  1707. copyPasteTreeToEmptyTarget: 0,
  1708. fallbackAdd: 0,
  1709. errors: 0,
  1710. };
  1711. const errors = [];
  1712. const operationLog = [];
  1713. const phaseTimeoutMs = Math.max(5000, Number(config.readyTimeoutMs || 0) + 5000);
  1714. const opReadTimeoutMs = Math.max(2000, Number(config.opTimeoutMs || 0) * 2);
  1715. installFatalWarningTrap();
  1716. installWsCapture();
  1717. clearFatalSignalState();
  1718. await withTimeout(waitForEditorReady(), phaseTimeoutMs, 'waitForEditorReady');
  1719. failIfFatalSignalSeen();
  1720. const anchor = await withTimeout(getAnchor(), phaseTimeoutMs, 'getAnchor');
  1721. await withTimeout(ensureClientRootBlock(anchor), phaseTimeoutMs, 'ensureClientRootBlock');
  1722. installReplayTxCapture();
  1723. const initialManaged = await withTimeout(listManagedBlocks(), phaseTimeoutMs, 'listManagedBlocks');
  1724. if (!initialManaged.length) {
  1725. await withTimeout(
  1726. logseq.api.insert_block(anchor.uuid, config.markerPrefix + ' seed', {
  1727. sibling: true,
  1728. before: false,
  1729. focus: false,
  1730. }),
  1731. phaseTimeoutMs,
  1732. 'insert seed block'
  1733. );
  1734. }
  1735. const initialDb = await withTimeout(
  1736. captureInitialDbSnapshot(),
  1737. phaseTimeoutMs,
  1738. 'captureInitialDbSnapshot'
  1739. );
  1740. replayCaptureStoreEntry.initialDb = initialDb;
  1741. replayCaptureStoreEntry.updatedAt = Date.now();
  1742. let replaySnapshotState = {
  1743. ok: initialDb?.ok === true,
  1744. reason: initialDb?.reason || null,
  1745. state: snapshotBlocksToStateMap(initialDb?.blocks),
  1746. };
  1747. const appendReplayFallbackTxFromSnapshot = async (opIndex) => {
  1748. if (!replayCaptureEnabled) return;
  1749. const nextSnapshot = await captureChecksumStateMap();
  1750. if (!nextSnapshot || nextSnapshot.ok !== true || !nextSnapshot.state) {
  1751. replaySnapshotState = nextSnapshot;
  1752. return;
  1753. }
  1754. if (!replaySnapshotState || replaySnapshotState.ok !== true || !replaySnapshotState.state) {
  1755. replaySnapshotState = nextSnapshot;
  1756. return;
  1757. }
  1758. const alreadyCaptured = replayCaptureState.txLog.some((entry) => entry?.opIndex === opIndex);
  1759. const datoms = replayDatomEntriesFromStateDiff(replaySnapshotState.state, nextSnapshot.state);
  1760. if (!alreadyCaptured && datoms.length > 0) {
  1761. const entry = {
  1762. capturedAt: Date.now(),
  1763. opIndex,
  1764. source: 'snapshot-diff',
  1765. datoms,
  1766. };
  1767. replayCaptureState.txLog.push(entry);
  1768. replayCaptureStoreEntry.txCapture.txLog.push(entry);
  1769. replayCaptureStoreEntry.txCapture.totalTx = replayCaptureStoreEntry.txCapture.txLog.length;
  1770. replayCaptureStoreEntry.updatedAt = Date.now();
  1771. }
  1772. replaySnapshotState = nextSnapshot;
  1773. };
  1774. let executed = 0;
  1775. for (let i = 0; i < config.plan.length; i += 1) {
  1776. failIfFatalSignalSeen();
  1777. const requested = config.plan[i];
  1778. const operable = await withTimeout(
  1779. listOperableBlocks(),
  1780. opReadTimeoutMs,
  1781. 'listOperableBlocks before operation'
  1782. );
  1783. let operation = chooseRunnableOperation(requested, operable.length);
  1784. if (operation !== requested) {
  1785. counts.fallbackAdd += 1;
  1786. }
  1787. try {
  1788. await sleep(Math.floor(nextRandom() * 10));
  1789. replayCaptureState.currentOpIndex = i;
  1790. const runOperation = async () => {
  1791. if (operation === 'add') {
  1792. const target = operable.length > 0 ? randomItem(operable) : anchor;
  1793. const content = nextRandom() < 0.2 ? '' : config.markerPrefix + ' add-' + i;
  1794. const asChild = operable.length > 0 && nextRandom() < 0.35;
  1795. const inserted = await logseq.api.insert_block(target.uuid, content, {
  1796. sibling: !asChild,
  1797. before: false,
  1798. focus: false,
  1799. });
  1800. return {
  1801. kind: 'add',
  1802. targetUuid: target.uuid || null,
  1803. insertedUuid: inserted?.uuid || null,
  1804. content,
  1805. sibling: !asChild,
  1806. before: false,
  1807. };
  1808. }
  1809. if (operation === 'copyPaste') {
  1810. const pageBlocks = await listPageBlocks();
  1811. const copyPool = (operable.length > 0 ? operable : pageBlocks).filter((b) => b?.uuid);
  1812. if (copyPool.length === 0) {
  1813. throw new Error('No blocks available for copyPaste');
  1814. }
  1815. const source = randomItem(copyPool);
  1816. const target = randomItem(copyPool);
  1817. await logseq.api.select_block(source.uuid);
  1818. await logseq.api.invoke_external_command('logseq.editor/copy');
  1819. const latestSource = await logseq.api.get_block(source.uuid);
  1820. const sourceContent = latestSource?.content || source.content || '';
  1821. const copiedContent =
  1822. config.markerPrefix + ' copy-' + i + (sourceContent ? ' :: ' + sourceContent : '');
  1823. const inserted = await logseq.api.insert_block(target.uuid, copiedContent, {
  1824. sibling: true,
  1825. before: false,
  1826. focus: false,
  1827. });
  1828. return {
  1829. kind: 'copyPaste',
  1830. sourceUuid: source.uuid || null,
  1831. targetUuid: target.uuid || null,
  1832. insertedUuid: inserted?.uuid || null,
  1833. copiedContent,
  1834. };
  1835. }
  1836. if (operation === 'copyPasteTreeToEmptyTarget') {
  1837. const pageBlocks = await listPageBlocks();
  1838. const treePool = (operable.length >= 2 ? operable : pageBlocks).filter((b) => b?.uuid);
  1839. if (treePool.length < 2) {
  1840. throw new Error('Not enough blocks available for multi-block copy');
  1841. }
  1842. const sources = pickRandomGroup(treePool, 2, 4);
  1843. const sourceTrees = [];
  1844. for (const source of sources) {
  1845. const sourceTree = await logseq.api.get_block(source.uuid, { includeChildren: true });
  1846. if (sourceTree?.uuid) {
  1847. sourceTrees.push(sourceTree);
  1848. }
  1849. }
  1850. if (sourceTrees.length === 0) {
  1851. throw new Error('Failed to load source tree blocks');
  1852. }
  1853. const treeTarget = operable.length > 0 ? randomItem(operable) : anchor;
  1854. const emptyTarget = await logseq.api.insert_block(treeTarget.uuid, '', {
  1855. sibling: true,
  1856. before: false,
  1857. focus: false,
  1858. });
  1859. if (!emptyTarget?.uuid) {
  1860. throw new Error('Failed to create empty target block');
  1861. }
  1862. await logseq.api.update_block(emptyTarget.uuid, '');
  1863. const payload = sourceTrees.map((tree, idx) => {
  1864. const node = toBatchTree(tree);
  1865. const origin = typeof node.content === 'string' && node.content.length > 0
  1866. ? ' :: ' + node.content
  1867. : '';
  1868. node.content = config.markerPrefix + ' tree-copy-' + i + '-' + idx + origin;
  1869. return node;
  1870. });
  1871. let fallbackToSingleTree = false;
  1872. try {
  1873. await logseq.api.insert_batch_block(emptyTarget.uuid, payload, { sibling: false });
  1874. } catch (_error) {
  1875. fallbackToSingleTree = true;
  1876. for (const tree of sourceTrees) {
  1877. await logseq.api.insert_batch_block(emptyTarget.uuid, toBatchTree(tree), { sibling: false });
  1878. }
  1879. }
  1880. return {
  1881. kind: 'copyPasteTreeToEmptyTarget',
  1882. treeTargetUuid: treeTarget.uuid || null,
  1883. emptyTargetUuid: emptyTarget.uuid || null,
  1884. sourceUuids: sourceTrees.map((tree) => tree?.uuid).filter(Boolean),
  1885. payloadSize: payload.length,
  1886. fallbackToSingleTree,
  1887. };
  1888. }
  1889. if (operation === 'move') {
  1890. const source = randomItem(operable);
  1891. const candidates = operable.filter((block) => block.uuid !== source.uuid);
  1892. const target = randomItem(candidates);
  1893. const before = nextRandom() < 0.5;
  1894. await logseq.api.move_block(source.uuid, target.uuid, {
  1895. before,
  1896. children: false,
  1897. });
  1898. return {
  1899. kind: 'move',
  1900. sourceUuid: source.uuid || null,
  1901. targetUuid: target.uuid || null,
  1902. before,
  1903. children: false,
  1904. };
  1905. }
  1906. if (operation === 'indent') {
  1907. const candidate = await ensureIndentCandidate(operable, anchor, i);
  1908. const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
  1909. if (!prevUuid) {
  1910. throw new Error('No previous sibling for indent candidate');
  1911. }
  1912. await logseq.api.move_block(candidate.uuid, prevUuid, {
  1913. before: false,
  1914. children: true,
  1915. });
  1916. return {
  1917. kind: 'indent',
  1918. candidateUuid: candidate.uuid || null,
  1919. targetUuid: prevUuid,
  1920. before: false,
  1921. children: true,
  1922. };
  1923. }
  1924. if (operation === 'outdent') {
  1925. const candidate = await ensureOutdentCandidate(operable, anchor, i);
  1926. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  1927. const parentId = full?.parent?.id;
  1928. const pageId = full?.page?.id;
  1929. if (!parentId || !pageId || parentId === pageId) {
  1930. throw new Error('Outdent candidate is not nested');
  1931. }
  1932. const parent = await logseq.api.get_block(parentId, { includeChildren: false });
  1933. if (!parent?.uuid) {
  1934. throw new Error('Cannot resolve parent block for outdent');
  1935. }
  1936. await logseq.api.move_block(candidate.uuid, parent.uuid, {
  1937. before: false,
  1938. children: false,
  1939. });
  1940. return {
  1941. kind: 'outdent',
  1942. candidateUuid: candidate.uuid || null,
  1943. targetUuid: parent.uuid || null,
  1944. before: false,
  1945. children: false,
  1946. };
  1947. }
  1948. if (operation === 'delete') {
  1949. const candidates = operable.filter((block) => block.uuid !== anchor.uuid && !isClientRootBlock(block));
  1950. const victimPool = candidates.length > 0 ? candidates : operable;
  1951. const victim = randomItem(victimPool);
  1952. if (isClientRootBlock(victim)) {
  1953. throw new Error('Skip deleting protected client root block');
  1954. }
  1955. await logseq.api.remove_block(victim.uuid);
  1956. return {
  1957. kind: 'delete',
  1958. victimUuid: victim.uuid || null,
  1959. };
  1960. }
  1961. if (operation === 'undo') {
  1962. await logseq.api.invoke_external_command('logseq.editor/undo');
  1963. await sleep(config.undoRedoDelayMs);
  1964. return { kind: 'undo' };
  1965. }
  1966. if (operation === 'redo') {
  1967. await logseq.api.invoke_external_command('logseq.editor/redo');
  1968. await sleep(config.undoRedoDelayMs);
  1969. return { kind: 'redo' };
  1970. }
  1971. return { kind: operation };
  1972. };
  1973. const opDetail = await withTimeout(runOperation(), config.opTimeoutMs, operation + ' operation');
  1974. failIfFatalSignalSeen();
  1975. try {
  1976. await withTimeout(
  1977. appendReplayFallbackTxFromSnapshot(i),
  1978. opReadTimeoutMs,
  1979. 'appendReplayFallbackTxFromSnapshot'
  1980. );
  1981. } catch (_error) {
  1982. // best-effort fallback capture
  1983. }
  1984. counts[operation] += 1;
  1985. executed += 1;
  1986. const opEntry = { index: i, requested, executedAs: operation, detail: opDetail || null };
  1987. operationLog.push(opEntry);
  1988. replayCaptureStoreEntry.opLog.push(opEntry);
  1989. replayCaptureStoreEntry.updatedAt = Date.now();
  1990. } catch (error) {
  1991. counts.errors += 1;
  1992. errors.push({
  1993. index: i,
  1994. requested,
  1995. attempted: operation,
  1996. message: String(error?.message || error),
  1997. });
  1998. try {
  1999. const recoveryOperable = await withTimeout(
  2000. listOperableBlocks(),
  2001. opReadTimeoutMs,
  2002. 'listOperableBlocks for recovery'
  2003. );
  2004. const target = recoveryOperable.length > 0 ? randomItem(recoveryOperable) : anchor;
  2005. await withTimeout(
  2006. logseq.api.insert_block(target.uuid, config.markerPrefix + ' recovery-' + i, {
  2007. sibling: true,
  2008. before: false,
  2009. focus: false,
  2010. }),
  2011. opReadTimeoutMs,
  2012. 'insert recovery block'
  2013. );
  2014. counts.add += 1;
  2015. executed += 1;
  2016. try {
  2017. await withTimeout(
  2018. appendReplayFallbackTxFromSnapshot(i),
  2019. opReadTimeoutMs,
  2020. 'appendReplayFallbackTxFromSnapshot-recovery'
  2021. );
  2022. } catch (_error) {
  2023. // best-effort fallback capture
  2024. }
  2025. const opEntry = {
  2026. index: i,
  2027. requested,
  2028. executedAs: 'add',
  2029. detail: {
  2030. kind: 'recovery-add',
  2031. targetUuid: target.uuid || null,
  2032. },
  2033. };
  2034. operationLog.push(opEntry);
  2035. replayCaptureStoreEntry.opLog.push(opEntry);
  2036. replayCaptureStoreEntry.updatedAt = Date.now();
  2037. } catch (recoveryError) {
  2038. errors.push({
  2039. index: i,
  2040. requested,
  2041. attempted: 'recovery-add',
  2042. message: String(recoveryError?.message || recoveryError),
  2043. });
  2044. break;
  2045. }
  2046. } finally {
  2047. replayCaptureState.currentOpIndex = null;
  2048. }
  2049. }
  2050. let checksum = null;
  2051. const warnings = [];
  2052. failIfFatalSignalSeen();
  2053. if (config.verifyChecksum) {
  2054. try {
  2055. checksum = await withTimeout(
  2056. runChecksumDiagnostics(),
  2057. Math.max(
  2058. 45000,
  2059. Number(config.syncSettleTimeoutMs || 0) +
  2060. Number(config.readyTimeoutMs || 0) +
  2061. 10000
  2062. ),
  2063. 'runChecksumDiagnostics'
  2064. );
  2065. } catch (error) {
  2066. checksum = {
  2067. ok: false,
  2068. reason: String(error?.message || error),
  2069. timedOut: true,
  2070. };
  2071. }
  2072. if (!checksum.ok) {
  2073. warnings.push({
  2074. index: config.plan.length,
  2075. requested: 'verifyChecksum',
  2076. attempted: 'verifyChecksum',
  2077. message: checksum.reason || 'checksum mismatch',
  2078. checksum,
  2079. });
  2080. }
  2081. }
  2082. const finalManaged = await withTimeout(listManagedBlocks(), phaseTimeoutMs, 'final listManagedBlocks');
  2083. replayCaptureState.enabled = false;
  2084. const replayTxCapture = {
  2085. enabled: replayCaptureEnabled,
  2086. installed: replayCaptureState.installed === true,
  2087. installReason: replayCaptureState.installReason,
  2088. totalTx: replayCaptureState.txLog.length,
  2089. txLog: replayCaptureState.txLog,
  2090. };
  2091. replayCaptureStoreEntry.txCapture = replayTxCapture;
  2092. replayCaptureStoreEntry.updatedAt = Date.now();
  2093. return {
  2094. ok: errors.length === 0,
  2095. requestedOps: config.plan.length,
  2096. executedOps: executed,
  2097. counts,
  2098. markerPrefix: config.markerPrefix,
  2099. anchorUuid: anchor.uuid,
  2100. finalManagedCount: finalManaged.length,
  2101. sampleManaged: finalManaged.slice(0, 5).map((block) => ({
  2102. uuid: block.uuid,
  2103. content: block.content,
  2104. })),
  2105. errorCount: errors.length,
  2106. errors: errors.slice(0, 20),
  2107. warnings: warnings.slice(0, 20),
  2108. rtcLogs: getRtcLogList(),
  2109. consoleLogs: Array.isArray(consoleCaptureEntry) ? [...consoleCaptureEntry] : [],
  2110. wsMessages: {
  2111. installed: wsCaptureEntry?.installed === true,
  2112. installReason: wsCaptureEntry?.installReason || null,
  2113. outbound: Array.isArray(wsCaptureEntry?.outbound) ? [...wsCaptureEntry.outbound] : [],
  2114. inbound: Array.isArray(wsCaptureEntry?.inbound) ? [...wsCaptureEntry.inbound] : [],
  2115. },
  2116. requestedPlan: Array.isArray(config.plan) ? [...config.plan] : [],
  2117. opLog: operationLog,
  2118. opLogSample: operationLog.slice(0, 20),
  2119. initialDb,
  2120. txCapture: replayTxCapture,
  2121. checksum,
  2122. };
  2123. })())()`;
  2124. }
  2125. function buildCleanupTodayPageProgram(config = {}) {
  2126. const cleanupConfig = {
  2127. cleanupTodayPage: true,
  2128. ...(config || {}),
  2129. };
  2130. return `(() => (async () => {
  2131. const config = ${JSON.stringify(cleanupConfig)};
  2132. const asPageName = (pageLike) => {
  2133. if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
  2134. if (!pageLike || typeof pageLike !== 'object') return null;
  2135. if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
  2136. if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
  2137. if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
  2138. return null;
  2139. };
  2140. const purgePageBlocks = async (pageName) => {
  2141. if (!pageName) {
  2142. return { ok: false, pageName, reason: 'empty page name' };
  2143. }
  2144. if (!globalThis.logseq?.api?.get_page_blocks_tree || !globalThis.logseq?.api?.remove_block) {
  2145. return { ok: false, pageName, reason: 'page block APIs unavailable' };
  2146. }
  2147. let tree = [];
  2148. try {
  2149. tree = await logseq.api.get_page_blocks_tree(pageName);
  2150. } catch (error) {
  2151. return { ok: false, pageName, reason: 'failed to read page tree: ' + String(error?.message || error) };
  2152. }
  2153. const topLevel = Array.isArray(tree)
  2154. ? tree.map((block) => block?.uuid).filter(Boolean)
  2155. : [];
  2156. for (const uuid of topLevel) {
  2157. try {
  2158. await logseq.api.remove_block(uuid);
  2159. } catch (_error) {
  2160. // best-effort cleanup; continue deleting remaining blocks
  2161. }
  2162. }
  2163. return {
  2164. ok: true,
  2165. pageName,
  2166. removedBlocks: topLevel.length,
  2167. };
  2168. };
  2169. try {
  2170. const pages = [];
  2171. if (!globalThis.logseq?.api?.get_today_page) {
  2172. return { ok: false, reason: 'today page API unavailable' };
  2173. }
  2174. const today = await logseq.api.get_today_page();
  2175. const todayName = asPageName(today);
  2176. if (todayName) {
  2177. pages.push(todayName);
  2178. }
  2179. const uniquePages = Array.from(new Set(pages.filter(Boolean)));
  2180. const pageResults = [];
  2181. for (const pageName of uniquePages) {
  2182. const pageResult = await purgePageBlocks(pageName);
  2183. let deleted = false;
  2184. let deleteError = null;
  2185. if (globalThis.logseq?.api?.delete_page) {
  2186. try {
  2187. await logseq.api.delete_page(pageName);
  2188. deleted = true;
  2189. } catch (error) {
  2190. deleteError = String(error?.message || error);
  2191. }
  2192. }
  2193. pageResults.push({
  2194. ...pageResult,
  2195. deleted,
  2196. deleteError,
  2197. });
  2198. }
  2199. return {
  2200. ok: pageResults.every((item) => item.ok),
  2201. pages: pageResults,
  2202. };
  2203. } catch (error) {
  2204. return { ok: false, reason: String(error?.message || error) };
  2205. }
  2206. })())()`;
  2207. }
  2208. function buildGraphBootstrapProgram(config) {
  2209. return `(() => (async () => {
  2210. const config = ${JSON.stringify(config)};
  2211. const lower = (value) => String(value || '').toLowerCase();
  2212. const targetGraphLower = lower(config.graphName);
  2213. const stateKey = '__logseqOpBootstrapState';
  2214. const state = (window[stateKey] && typeof window[stateKey] === 'object') ? window[stateKey] : {};
  2215. window[stateKey] = state;
  2216. if (state.targetGraph !== config.graphName || state.runId !== config.runId) {
  2217. state.initialGraphName = null;
  2218. state.initialRepoName = null;
  2219. state.initialTargetMatched = null;
  2220. state.passwordAttempts = 0;
  2221. state.refreshCount = 0;
  2222. state.graphDetected = false;
  2223. state.graphCardClicked = false;
  2224. state.passwordSubmitted = false;
  2225. state.actionTriggered = false;
  2226. state.gotoGraphsOk = false;
  2227. state.gotoGraphsError = null;
  2228. state.downloadStarted = false;
  2229. state.downloadCompleted = false;
  2230. state.downloadCompletionSource = null;
  2231. state.lastDownloadLog = null;
  2232. state.lastRefreshAt = 0;
  2233. state.lastGraphClickAt = 0;
  2234. state.targetStateStableHits = 0;
  2235. state.switchAttempts = 0;
  2236. }
  2237. state.runId = config.runId;
  2238. state.targetGraph = config.graphName;
  2239. if (typeof state.passwordAttempts !== 'number') state.passwordAttempts = 0;
  2240. if (typeof state.refreshCount !== 'number') state.refreshCount = 0;
  2241. if (typeof state.graphDetected !== 'boolean') state.graphDetected = false;
  2242. if (typeof state.graphCardClicked !== 'boolean') state.graphCardClicked = false;
  2243. if (typeof state.passwordSubmitted !== 'boolean') state.passwordSubmitted = false;
  2244. if (typeof state.actionTriggered !== 'boolean') state.actionTriggered = false;
  2245. if (typeof state.gotoGraphsOk !== 'boolean') state.gotoGraphsOk = false;
  2246. if (typeof state.gotoGraphsError !== 'string' && state.gotoGraphsError !== null) state.gotoGraphsError = null;
  2247. if (typeof state.downloadStarted !== 'boolean') state.downloadStarted = false;
  2248. if (typeof state.downloadCompleted !== 'boolean') state.downloadCompleted = false;
  2249. if (typeof state.downloadCompletionSource !== 'string' && state.downloadCompletionSource !== null) {
  2250. state.downloadCompletionSource = null;
  2251. }
  2252. if (typeof state.lastDownloadLog !== 'object' && state.lastDownloadLog !== null) {
  2253. state.lastDownloadLog = null;
  2254. }
  2255. if (typeof state.initialRepoName !== 'string' && state.initialRepoName !== null) {
  2256. state.initialRepoName = null;
  2257. }
  2258. if (typeof state.initialTargetMatched !== 'boolean' && state.initialTargetMatched !== null) {
  2259. state.initialTargetMatched = null;
  2260. }
  2261. if (typeof state.lastRefreshAt !== 'number') {
  2262. state.lastRefreshAt = 0;
  2263. }
  2264. if (typeof state.lastGraphClickAt !== 'number') {
  2265. state.lastGraphClickAt = 0;
  2266. }
  2267. if (typeof state.targetStateStableHits !== 'number') {
  2268. state.targetStateStableHits = 0;
  2269. }
  2270. if (typeof state.switchAttempts !== 'number') {
  2271. state.switchAttempts = 0;
  2272. }
  2273. const setInputValue = (input, value) => {
  2274. if (!input) return;
  2275. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
  2276. if (setter) {
  2277. setter.call(input, value);
  2278. } else {
  2279. input.value = value;
  2280. }
  2281. input.dispatchEvent(new Event('input', { bubbles: true }));
  2282. input.dispatchEvent(new Event('change', { bubbles: true }));
  2283. };
  2284. const dispatchClick = (node) => {
  2285. if (!(node instanceof HTMLElement)) return false;
  2286. try {
  2287. node.scrollIntoView({ block: 'center', inline: 'center' });
  2288. } catch (_error) {
  2289. // ignore scroll failures
  2290. }
  2291. try {
  2292. node.focus();
  2293. } catch (_error) {
  2294. // ignore focus failures
  2295. }
  2296. try {
  2297. node.click();
  2298. } catch (_error) {
  2299. // continue with explicit events
  2300. }
  2301. node.dispatchEvent(new MouseEvent('mousedown', { view: window, bubbles: true, cancelable: true }));
  2302. node.dispatchEvent(new MouseEvent('mouseup', { view: window, bubbles: true, cancelable: true }));
  2303. node.dispatchEvent(new MouseEvent('click', { view: window, bubbles: true, cancelable: true }));
  2304. return true;
  2305. };
  2306. const graphNameMatchesTarget = (graphName) => {
  2307. const value = lower(graphName);
  2308. if (!value) return false;
  2309. return (
  2310. value === targetGraphLower ||
  2311. value.endsWith('/' + targetGraphLower) ||
  2312. value.endsWith('_' + targetGraphLower) ||
  2313. value.includes('logseq_db_' + targetGraphLower)
  2314. );
  2315. };
  2316. const stateMatchesTarget = (repoName, graphName) => {
  2317. const hasRepo = typeof repoName === 'string' && repoName.length > 0;
  2318. const hasGraph = typeof graphName === 'string' && graphName.length > 0;
  2319. const repoMatches = hasRepo ? graphNameMatchesTarget(repoName) : false;
  2320. const graphMatches = hasGraph ? graphNameMatchesTarget(graphName) : false;
  2321. if (hasRepo && hasGraph) {
  2322. return repoMatches && graphMatches;
  2323. }
  2324. if (hasRepo) return repoMatches;
  2325. if (hasGraph) return graphMatches;
  2326. return false;
  2327. };
  2328. const listGraphCards = () =>
  2329. Array.from(document.querySelectorAll('div[data-testid^="logseq_db_"]'));
  2330. const findGraphCard = () => {
  2331. const exact = document.querySelector('div[data-testid="logseq_db_' + config.graphName + '"]');
  2332. if (exact) return exact;
  2333. const byTestId = listGraphCards()
  2334. .find((card) => lower(card.getAttribute('data-testid')).includes(targetGraphLower));
  2335. if (byTestId) return byTestId;
  2336. return listGraphCards()
  2337. .find((card) => lower(card.textContent).includes(targetGraphLower));
  2338. };
  2339. const clickRefresh = () => {
  2340. const candidates = Array.from(document.querySelectorAll('button,span,a'));
  2341. const refreshNode = candidates.find((el) => (el.textContent || '').trim() === 'Refresh');
  2342. const clickable = refreshNode ? (refreshNode.closest('button') || refreshNode) : null;
  2343. return dispatchClick(clickable);
  2344. };
  2345. const clickGraphCard = (card) => {
  2346. if (!card) return false;
  2347. const anchors = Array.from(card.querySelectorAll('a'));
  2348. const exactAnchor = anchors.find((el) => lower(el.textContent).trim() === targetGraphLower);
  2349. const looseAnchor = anchors.find((el) => lower(el.textContent).includes(targetGraphLower));
  2350. const anyAnchor = anchors[0];
  2351. const actionButton = Array.from(card.querySelectorAll('button'))
  2352. .find((el) => lower(el.textContent).includes(targetGraphLower));
  2353. const target = exactAnchor || looseAnchor || anyAnchor || actionButton || card;
  2354. return dispatchClick(target);
  2355. };
  2356. const getCurrentGraphName = async () => {
  2357. try {
  2358. if (!globalThis.logseq?.api?.get_current_graph) return null;
  2359. const current = await logseq.api.get_current_graph();
  2360. if (!current || typeof current !== 'object') return null;
  2361. if (typeof current.name === 'string' && current.name.length > 0) return current.name;
  2362. if (typeof current.url === 'string' && current.url.length > 0) {
  2363. const parts = current.url.split('/').filter(Boolean);
  2364. return parts[parts.length - 1] || null;
  2365. }
  2366. } catch (_error) {
  2367. // ignore
  2368. }
  2369. return null;
  2370. };
  2371. const getCurrentRepoName = () => {
  2372. try {
  2373. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  2374. const value = logseq.api.get_state_from_store(['git/current-repo']);
  2375. return typeof value === 'string' && value.length > 0 ? value : null;
  2376. } catch (_error) {
  2377. return null;
  2378. }
  2379. };
  2380. const getDownloadingGraphUuid = () => {
  2381. try {
  2382. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  2383. return logseq.api.get_state_from_store(['rtc/downloading-graph-uuid']);
  2384. } catch (_error) {
  2385. return null;
  2386. }
  2387. };
  2388. const getRtcLog = () => {
  2389. try {
  2390. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  2391. return logseq.api.get_state_from_store(['rtc/log']);
  2392. } catch (_error) {
  2393. return null;
  2394. }
  2395. };
  2396. const asLower = (value) => String(value || '').toLowerCase();
  2397. const parseRtcDownloadLog = (value) => {
  2398. if (!value || typeof value !== 'object') return null;
  2399. const type = value.type || value['type'] || null;
  2400. const typeLower = asLower(type);
  2401. if (!typeLower.includes('rtc.log/download')) return null;
  2402. const subType =
  2403. value['sub-type'] ||
  2404. value.subType ||
  2405. value.subtype ||
  2406. value.sub_type ||
  2407. null;
  2408. const graphUuid =
  2409. value['graph-uuid'] ||
  2410. value.graphUuid ||
  2411. value.graph_uuid ||
  2412. null;
  2413. const message = value.message || null;
  2414. return {
  2415. type: String(type || ''),
  2416. subType: String(subType || ''),
  2417. graphUuid: graphUuid ? String(graphUuid) : null,
  2418. message: message ? String(message) : null,
  2419. };
  2420. };
  2421. const probeGraphReady = async () => {
  2422. try {
  2423. if (!globalThis.logseq?.api?.get_current_page_blocks_tree) {
  2424. return { ok: false, reason: 'get_current_page_blocks_tree unavailable' };
  2425. }
  2426. await logseq.api.get_current_page_blocks_tree();
  2427. return { ok: true, reason: null };
  2428. } catch (error) {
  2429. return { ok: false, reason: String(error?.message || error) };
  2430. }
  2431. };
  2432. const initialGraphName = await getCurrentGraphName();
  2433. const initialRepoName = getCurrentRepoName();
  2434. const initialTargetMatched = stateMatchesTarget(initialRepoName, initialGraphName);
  2435. if (!state.initialGraphName && initialGraphName) {
  2436. state.initialGraphName = initialGraphName;
  2437. }
  2438. if (!state.initialRepoName && initialRepoName) {
  2439. state.initialRepoName = initialRepoName;
  2440. }
  2441. if (state.initialTargetMatched === null) {
  2442. state.initialTargetMatched = initialTargetMatched;
  2443. }
  2444. const shouldForceSelection =
  2445. (config.forceSelection === true && !state.graphCardClicked && !state.downloadStarted) ||
  2446. !initialTargetMatched;
  2447. let onGraphsPage = location.hash.includes('/graphs');
  2448. if ((shouldForceSelection || !initialTargetMatched) && !onGraphsPage) {
  2449. try {
  2450. location.hash = '#/graphs';
  2451. state.gotoGraphsOk = true;
  2452. } catch (error) {
  2453. state.gotoGraphsError = String(error?.message || error);
  2454. }
  2455. onGraphsPage = location.hash.includes('/graphs');
  2456. }
  2457. const modal = document.querySelector('.e2ee-password-modal-content');
  2458. const passwordModalVisible = !!modal;
  2459. let passwordAttempted = false;
  2460. let passwordSubmittedThisStep = false;
  2461. if (modal) {
  2462. const passwordInputs = Array.from(
  2463. modal.querySelectorAll('input[type="password"], .ls-toggle-password-input input, input')
  2464. );
  2465. if (passwordInputs.length >= 2) {
  2466. setInputValue(passwordInputs[0], config.password);
  2467. setInputValue(passwordInputs[1], config.password);
  2468. passwordAttempted = true;
  2469. } else if (passwordInputs.length === 1) {
  2470. setInputValue(passwordInputs[0], config.password);
  2471. passwordAttempted = true;
  2472. }
  2473. if (passwordAttempted) {
  2474. state.passwordAttempts += 1;
  2475. }
  2476. const submitButton = Array.from(modal.querySelectorAll('button'))
  2477. .find((button) => /(submit|open|unlock|confirm|enter)/i.test((button.textContent || '').trim()));
  2478. if (submitButton && !submitButton.disabled) {
  2479. passwordSubmittedThisStep = dispatchClick(submitButton);
  2480. state.passwordSubmitted = state.passwordSubmitted || passwordSubmittedThisStep;
  2481. state.actionTriggered = state.actionTriggered || passwordSubmittedThisStep;
  2482. }
  2483. }
  2484. let graphCardClickedThisStep = false;
  2485. let refreshClickedThisStep = false;
  2486. if (location.hash.includes('/graphs')) {
  2487. const card = findGraphCard();
  2488. if (card) {
  2489. const now = Date.now();
  2490. state.graphDetected = true;
  2491. if (!state.graphCardClicked && now - state.lastGraphClickAt >= 500) {
  2492. graphCardClickedThisStep = clickGraphCard(card);
  2493. if (graphCardClickedThisStep) {
  2494. state.lastGraphClickAt = now;
  2495. state.switchAttempts += 1;
  2496. }
  2497. state.graphCardClicked = state.graphCardClicked || graphCardClickedThisStep;
  2498. state.actionTriggered = state.actionTriggered || graphCardClickedThisStep;
  2499. }
  2500. } else {
  2501. const now = Date.now();
  2502. if (now - state.lastRefreshAt >= 2000) {
  2503. refreshClickedThisStep = clickRefresh();
  2504. if (refreshClickedThisStep) {
  2505. state.refreshCount += 1;
  2506. state.lastRefreshAt = now;
  2507. }
  2508. }
  2509. }
  2510. }
  2511. const downloadingGraphUuid = getDownloadingGraphUuid();
  2512. if (downloadingGraphUuid) {
  2513. state.actionTriggered = true;
  2514. state.downloadStarted = true;
  2515. }
  2516. const rtcDownloadLog = parseRtcDownloadLog(getRtcLog());
  2517. if (rtcDownloadLog) {
  2518. state.lastDownloadLog = rtcDownloadLog;
  2519. const subTypeLower = asLower(rtcDownloadLog.subType);
  2520. const messageLower = asLower(rtcDownloadLog.message);
  2521. if (subTypeLower.includes('download-progress') || subTypeLower.includes('downloadprogress')) {
  2522. state.downloadStarted = true;
  2523. }
  2524. if (
  2525. (subTypeLower.includes('download-completed') || subTypeLower.includes('downloadcompleted')) &&
  2526. messageLower.includes('ready')
  2527. ) {
  2528. state.downloadStarted = true;
  2529. state.downloadCompleted = true;
  2530. state.downloadCompletionSource = 'rtc-log';
  2531. }
  2532. }
  2533. const currentGraphName = await getCurrentGraphName();
  2534. const currentRepoName = getCurrentRepoName();
  2535. const onGraphsPageFinal = location.hash.includes('/graphs');
  2536. const repoMatchesTarget = graphNameMatchesTarget(currentRepoName);
  2537. const graphMatchesTarget = graphNameMatchesTarget(currentGraphName);
  2538. const switchedToTargetGraph = stateMatchesTarget(currentRepoName, currentGraphName) && !onGraphsPageFinal;
  2539. if (switchedToTargetGraph) {
  2540. state.targetStateStableHits += 1;
  2541. } else {
  2542. state.targetStateStableHits = 0;
  2543. }
  2544. if (
  2545. !switchedToTargetGraph &&
  2546. !onGraphsPageFinal &&
  2547. !passwordModalVisible &&
  2548. !state.downloadStarted &&
  2549. !state.graphCardClicked
  2550. ) {
  2551. try {
  2552. location.hash = '#/graphs';
  2553. state.gotoGraphsOk = true;
  2554. } catch (error) {
  2555. state.gotoGraphsError = String(error?.message || error);
  2556. }
  2557. }
  2558. const needsReadinessProbe =
  2559. switchedToTargetGraph &&
  2560. !passwordModalVisible &&
  2561. !downloadingGraphUuid;
  2562. const readyProbe = needsReadinessProbe
  2563. ? await probeGraphReady()
  2564. : { ok: false, reason: 'skipped' };
  2565. if (state.downloadStarted && !state.downloadCompleted && readyProbe.ok) {
  2566. state.downloadCompleted = true;
  2567. state.downloadCompletionSource = 'db-ready-probe';
  2568. }
  2569. const downloadLifecycleSatisfied = !state.downloadStarted || state.downloadCompleted;
  2570. const requiresAction = config.requireAction !== false;
  2571. const ok =
  2572. switchedToTargetGraph &&
  2573. !passwordModalVisible &&
  2574. !downloadingGraphUuid &&
  2575. readyProbe.ok &&
  2576. downloadLifecycleSatisfied &&
  2577. (!requiresAction || state.actionTriggered) &&
  2578. state.targetStateStableHits >= 2;
  2579. const availableCards = listGraphCards().slice(0, 10).map((card) => ({
  2580. dataTestId: card.getAttribute('data-testid'),
  2581. text: (card.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
  2582. }));
  2583. return {
  2584. ok,
  2585. targetGraph: config.graphName,
  2586. initialGraphName: state.initialGraphName || null,
  2587. initialRepoName: state.initialRepoName || null,
  2588. initialTargetMatched: state.initialTargetMatched,
  2589. currentGraphName,
  2590. currentRepoName,
  2591. gotoGraphsOk: state.gotoGraphsOk,
  2592. gotoGraphsError: state.gotoGraphsError,
  2593. onGraphsPage: onGraphsPageFinal,
  2594. downloadingGraphUuid,
  2595. switchedToTargetGraph,
  2596. repoMatchesTarget,
  2597. graphMatchesTarget,
  2598. readyProbe,
  2599. actionTriggered: state.actionTriggered,
  2600. graphDetected: state.graphDetected,
  2601. graphCardClicked: state.graphCardClicked,
  2602. graphCardClickedThisStep,
  2603. switchAttempts: state.switchAttempts,
  2604. refreshCount: state.refreshCount,
  2605. refreshClickedThisStep,
  2606. passwordAttempts: state.passwordAttempts,
  2607. passwordAttempted,
  2608. passwordModalVisible,
  2609. passwordSubmitted: state.passwordSubmitted,
  2610. passwordSubmittedThisStep,
  2611. downloadStarted: state.downloadStarted,
  2612. downloadCompleted: state.downloadCompleted,
  2613. downloadCompletionSource: state.downloadCompletionSource,
  2614. targetStateStableHits: state.targetStateStableHits,
  2615. lastDownloadLog: state.lastDownloadLog,
  2616. availableCards,
  2617. };
  2618. })())()`;
  2619. }
  2620. async function runGraphBootstrap(sessionName, args, runOptions) {
  2621. const deadline = Date.now() + args.switchTimeoutMs;
  2622. const bootstrapRunId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
  2623. let lastBootstrap = null;
  2624. while (Date.now() < deadline) {
  2625. const bootstrapProgram = buildGraphBootstrapProgram({
  2626. runId: bootstrapRunId,
  2627. graphName: args.graph,
  2628. password: args.e2ePassword,
  2629. forceSelection: true,
  2630. requireAction: true,
  2631. });
  2632. const bootstrapEvaluation = await runAgentBrowser(
  2633. sessionName,
  2634. ['eval', '--stdin'],
  2635. {
  2636. input: bootstrapProgram,
  2637. timeoutMs: BOOTSTRAP_EVAL_TIMEOUT_MS,
  2638. ...runOptions,
  2639. }
  2640. );
  2641. const bootstrap = bootstrapEvaluation?.data?.result;
  2642. if (!bootstrap || typeof bootstrap !== 'object') {
  2643. throw new Error('Graph bootstrap returned empty state for session ' + sessionName);
  2644. }
  2645. lastBootstrap = bootstrap;
  2646. if (bootstrap.ok) {
  2647. return bootstrap;
  2648. }
  2649. await sleep(250);
  2650. }
  2651. throw new Error(
  2652. 'Failed to switch/download graph "' + args.graph + '" within timeout. ' +
  2653. 'Last bootstrap state: ' + JSON.stringify(lastBootstrap)
  2654. );
  2655. }
  2656. function buildGraphProbeProgram(graphName) {
  2657. return `(() => (async () => {
  2658. const target = ${JSON.stringify(String(graphName || ''))}.toLowerCase();
  2659. const lower = (v) => String(v || '').toLowerCase();
  2660. const matches = (value) => {
  2661. const v = lower(value);
  2662. if (!v) return false;
  2663. return v === target || v.endsWith('/' + target) || v.endsWith('_' + target) || v.includes('logseq_db_' + target);
  2664. };
  2665. let currentGraphName = null;
  2666. let currentRepoName = null;
  2667. try {
  2668. if (globalThis.logseq?.api?.get_current_graph) {
  2669. const current = await logseq.api.get_current_graph();
  2670. currentGraphName = current?.name || current?.url || null;
  2671. }
  2672. } catch (_error) {
  2673. // ignore
  2674. }
  2675. try {
  2676. if (globalThis.logseq?.api?.get_state_from_store) {
  2677. currentRepoName = logseq.api.get_state_from_store(['git/current-repo']) || null;
  2678. }
  2679. } catch (_error) {
  2680. // ignore
  2681. }
  2682. const repoMatchesTarget = matches(currentRepoName);
  2683. const graphMatchesTarget = matches(currentGraphName);
  2684. const onGraphsPage = location.hash.includes('/graphs');
  2685. const stableTarget = (repoMatchesTarget || graphMatchesTarget) && !onGraphsPage;
  2686. return {
  2687. targetGraph: ${JSON.stringify(String(graphName || ''))},
  2688. currentGraphName,
  2689. currentRepoName,
  2690. repoMatchesTarget,
  2691. graphMatchesTarget,
  2692. onGraphsPage,
  2693. stableTarget,
  2694. };
  2695. })())()`;
  2696. }
  2697. async function ensureTargetGraphBeforeOps(sessionName, args, runOptions) {
  2698. let lastProbe = null;
  2699. let lastBootstrap = null;
  2700. for (let attempt = 0; attempt < 4; attempt += 1) {
  2701. const probeEval = await runAgentBrowser(
  2702. sessionName,
  2703. ['eval', '--stdin'],
  2704. {
  2705. input: buildGraphProbeProgram(args.graph),
  2706. ...runOptions,
  2707. }
  2708. );
  2709. const probe = probeEval?.data?.result;
  2710. lastProbe = probe;
  2711. if (probe?.stableTarget) {
  2712. return { ok: true, probe, bootstrap: lastBootstrap };
  2713. }
  2714. lastBootstrap = await runGraphBootstrap(sessionName, args, runOptions);
  2715. }
  2716. throw new Error(
  2717. 'Target graph verification failed before ops. ' +
  2718. 'Last probe: ' + JSON.stringify(lastProbe) + '. ' +
  2719. 'Last bootstrap: ' + JSON.stringify(lastBootstrap)
  2720. );
  2721. }
  2722. function buildSessionNames(baseSession, instances) {
  2723. if (instances <= 1) return [baseSession];
  2724. const sessions = [];
  2725. for (let i = 0; i < instances; i += 1) {
  2726. sessions.push(`${baseSession}-${i + 1}`);
  2727. }
  2728. return sessions;
  2729. }
  2730. function buildSimulationOperationPlan(totalOps, profile) {
  2731. if (profile === 'full') {
  2732. return buildOperationPlan(totalOps);
  2733. }
  2734. const fastOperationOrder = [
  2735. 'add',
  2736. 'add',
  2737. 'move',
  2738. 'delete',
  2739. 'indent',
  2740. 'outdent',
  2741. 'add',
  2742. 'move',
  2743. ];
  2744. const plan = [];
  2745. for (let i = 0; i < totalOps; i += 1) {
  2746. plan.push(fastOperationOrder[i % fastOperationOrder.length]);
  2747. }
  2748. return plan;
  2749. }
  2750. function shuffleOperationPlan(plan, rng = Math.random) {
  2751. const shuffled = Array.isArray(plan) ? [...plan] : [];
  2752. for (let i = shuffled.length - 1; i > 0; i -= 1) {
  2753. const j = Math.floor(rng() * (i + 1));
  2754. const tmp = shuffled[i];
  2755. shuffled[i] = shuffled[j];
  2756. shuffled[j] = tmp;
  2757. }
  2758. return shuffled;
  2759. }
  2760. function computeRendererEvalTimeoutMs(syncSettleTimeoutMs, opCount) {
  2761. return Math.max(
  2762. 1200000,
  2763. RENDERER_EVAL_BASE_TIMEOUT_MS +
  2764. (syncSettleTimeoutMs * 2) +
  2765. (opCount * 500) +
  2766. 30000
  2767. );
  2768. }
  2769. function buildReplayCaptureProbeProgram(markerPrefix) {
  2770. return `(() => {
  2771. const key = '__logseqOpReplayCaptureStore';
  2772. const consoleKey = '__logseqOpConsoleCaptureStore';
  2773. const wsKey = '__logseqOpWsCaptureStore';
  2774. const marker = ${JSON.stringify(String(markerPrefix || ''))};
  2775. const store = window[key];
  2776. const consoleStore = window[consoleKey];
  2777. const wsStore = window[wsKey];
  2778. const entry = store && typeof store === 'object' ? store[marker] : null;
  2779. const consoleEntry =
  2780. consoleStore && typeof consoleStore === 'object' ? consoleStore[marker] : null;
  2781. const wsEntry = wsStore && typeof wsStore === 'object' ? wsStore[marker] : null;
  2782. if (!entry && !consoleEntry && !wsEntry) return null;
  2783. return {
  2784. replayCapture: entry && typeof entry === 'object' ? entry : null,
  2785. consoleLogs: Array.isArray(consoleEntry) ? consoleEntry : [],
  2786. wsMessages: wsEntry && typeof wsEntry === 'object' ? wsEntry : null,
  2787. };
  2788. })()`;
  2789. }
  2790. async function collectFailureReplayCapture(sessionName, markerPrefix, runOptions) {
  2791. try {
  2792. const evaluation = await runAgentBrowser(
  2793. sessionName,
  2794. ['eval', '--stdin'],
  2795. {
  2796. input: buildReplayCaptureProbeProgram(markerPrefix),
  2797. timeoutMs: 20000,
  2798. ...runOptions,
  2799. }
  2800. );
  2801. const value = evaluation?.data?.result;
  2802. return value && typeof value === 'object' ? value : null;
  2803. } catch (_error) {
  2804. return null;
  2805. }
  2806. }
  2807. function summarizeRounds(rounds) {
  2808. return rounds.reduce(
  2809. (acc, round) => {
  2810. const roundCounts = round?.counts && typeof round.counts === 'object' ? round.counts : {};
  2811. for (const [k, v] of Object.entries(roundCounts)) {
  2812. acc.counts[k] = (acc.counts[k] || 0) + (Number(v) || 0);
  2813. }
  2814. acc.requestedOps += Number(round.requestedOps || 0);
  2815. acc.executedOps += Number(round.executedOps || 0);
  2816. acc.errorCount += Number(round.errorCount || 0);
  2817. if (round.ok !== true) {
  2818. acc.failedRounds.push(round.round);
  2819. }
  2820. return acc;
  2821. },
  2822. { counts: {}, requestedOps: 0, executedOps: 0, errorCount: 0, failedRounds: [] }
  2823. );
  2824. }
  2825. async function runSimulationForSession(sessionName, index, args, sharedConfig) {
  2826. if (args.resetSession) {
  2827. try {
  2828. await runAgentBrowser(sessionName, ['close'], {
  2829. autoConnect: false,
  2830. headed: false,
  2831. });
  2832. } catch (_error) {
  2833. // session may not exist yet
  2834. }
  2835. }
  2836. const runOptions = {
  2837. headed: args.headed,
  2838. autoConnect: args.autoConnect,
  2839. profile: sharedConfig.instanceProfiles[index] ?? null,
  2840. launchArgs: sharedConfig.effectiveLaunchArgs,
  2841. executablePath: sharedConfig.effectiveExecutablePath,
  2842. };
  2843. await runAgentBrowser(sessionName, ['open', args.url], runOptions);
  2844. await ensureActiveTabOnTargetUrl(sessionName, args.url, runOptions);
  2845. const rounds = [];
  2846. let bootstrap = null;
  2847. const fixedPlanForInstance =
  2848. sharedConfig.fixedPlansByInstance instanceof Map
  2849. ? sharedConfig.fixedPlansByInstance.get(index + 1)
  2850. : null;
  2851. const rendererEvalTimeoutMs = computeRendererEvalTimeoutMs(
  2852. args.syncSettleTimeoutMs,
  2853. Array.isArray(fixedPlanForInstance) && fixedPlanForInstance.length > 0
  2854. ? fixedPlanForInstance.length
  2855. : sharedConfig.plan.length
  2856. );
  2857. for (let round = 0; round < args.rounds; round += 1) {
  2858. const roundSeed = deriveSeed(
  2859. sharedConfig.seed ?? sharedConfig.runId,
  2860. sessionName,
  2861. index + 1,
  2862. round + 1
  2863. );
  2864. const roundRng = createSeededRng(roundSeed);
  2865. bootstrap = await runGraphBootstrap(sessionName, args, runOptions);
  2866. const clientPlan =
  2867. Array.isArray(fixedPlanForInstance) && fixedPlanForInstance.length > 0
  2868. ? [...fixedPlanForInstance]
  2869. : shuffleOperationPlan(sharedConfig.plan, roundRng);
  2870. const markerPrefix = `${sharedConfig.runPrefix}r${round + 1}-client-${index + 1}-`;
  2871. const rendererProgram = buildRendererProgram({
  2872. runPrefix: sharedConfig.runPrefix,
  2873. markerPrefix,
  2874. plan: clientPlan,
  2875. seed: roundSeed,
  2876. undoRedoDelayMs: args.undoRedoDelayMs,
  2877. readyTimeoutMs: RENDERER_READY_TIMEOUT_MS,
  2878. readyPollDelayMs: RENDERER_READY_POLL_DELAY_MS,
  2879. syncSettleTimeoutMs: args.syncSettleTimeoutMs,
  2880. opTimeoutMs: args.opTimeoutMs,
  2881. fallbackPageName: FALLBACK_PAGE_NAME,
  2882. verifyChecksum: args.verifyChecksum,
  2883. captureReplay: args.captureReplay,
  2884. });
  2885. try {
  2886. const evaluation = await runAgentBrowser(
  2887. sessionName,
  2888. ['eval', '--stdin'],
  2889. {
  2890. input: rendererProgram,
  2891. timeoutMs: rendererEvalTimeoutMs,
  2892. ...runOptions,
  2893. }
  2894. );
  2895. const value = evaluation?.data?.result;
  2896. if (!value) {
  2897. throw new Error(`Unexpected empty result from agent-browser eval (round ${round + 1})`);
  2898. }
  2899. rounds.push({
  2900. round: round + 1,
  2901. ...value,
  2902. });
  2903. } catch (error) {
  2904. const captured = await collectFailureReplayCapture(sessionName, markerPrefix, runOptions);
  2905. if (captured && typeof captured === 'object') {
  2906. const replayCapture =
  2907. captured.replayCapture && typeof captured.replayCapture === 'object'
  2908. ? captured.replayCapture
  2909. : {};
  2910. const fallbackOpLog = Array.isArray(replayCapture.opLog) ? replayCapture.opLog : [];
  2911. const fallbackTxCapture =
  2912. replayCapture.txCapture && typeof replayCapture.txCapture === 'object'
  2913. ? replayCapture.txCapture
  2914. : null;
  2915. const fallbackInitialDb =
  2916. replayCapture.initialDb && typeof replayCapture.initialDb === 'object'
  2917. ? replayCapture.initialDb
  2918. : null;
  2919. const fallbackConsoleLogs = Array.isArray(captured.consoleLogs)
  2920. ? captured.consoleLogs
  2921. : [];
  2922. const fallbackWsMessages =
  2923. captured.wsMessages && typeof captured.wsMessages === 'object'
  2924. ? captured.wsMessages
  2925. : null;
  2926. const fallbackExecutedOps = fallbackOpLog.length;
  2927. const roundResult = {
  2928. round: round + 1,
  2929. ok: false,
  2930. requestedOps: clientPlan.length,
  2931. executedOps: fallbackExecutedOps,
  2932. counts: {},
  2933. markerPrefix,
  2934. anchorUuid: null,
  2935. finalManagedCount: 0,
  2936. sampleManaged: [],
  2937. errorCount: 1,
  2938. errors: [
  2939. {
  2940. index: fallbackExecutedOps,
  2941. requested: 'eval',
  2942. attempted: 'eval',
  2943. message: String(error?.message || error),
  2944. },
  2945. ],
  2946. requestedPlan: Array.isArray(clientPlan) ? [...clientPlan] : [],
  2947. opLog: fallbackOpLog,
  2948. opLogSample: fallbackOpLog.slice(0, 20),
  2949. initialDb: fallbackInitialDb,
  2950. txCapture: fallbackTxCapture,
  2951. consoleLogs: fallbackConsoleLogs,
  2952. wsMessages: fallbackWsMessages,
  2953. checksum: null,
  2954. recoveredFromEvalFailure: true,
  2955. };
  2956. rounds.push(roundResult);
  2957. }
  2958. error.partialResult = {
  2959. ok: false,
  2960. rounds: [...rounds],
  2961. ...summarizeRounds(rounds),
  2962. };
  2963. throw error;
  2964. }
  2965. }
  2966. const summary = summarizeRounds(rounds);
  2967. const value = {
  2968. ok: summary.failedRounds.length === 0,
  2969. rounds,
  2970. requestedOps: summary.requestedOps,
  2971. executedOps: summary.executedOps,
  2972. counts: summary.counts,
  2973. errorCount: summary.errorCount,
  2974. failedRounds: summary.failedRounds,
  2975. };
  2976. value.runtime = {
  2977. session: sessionName,
  2978. instanceIndex: index + 1,
  2979. effectiveProfile: runOptions.profile,
  2980. effectiveLaunchArgs: sharedConfig.effectiveLaunchArgs,
  2981. effectiveExecutablePath: sharedConfig.effectiveExecutablePath,
  2982. bootstrap,
  2983. rounds: args.rounds,
  2984. opProfile: args.opProfile,
  2985. opTimeoutMs: args.opTimeoutMs,
  2986. seed: args.seed,
  2987. verifyChecksum: args.verifyChecksum,
  2988. captureReplay: args.captureReplay,
  2989. cleanupTodayPage: args.cleanupTodayPage,
  2990. autoConnect: args.autoConnect,
  2991. headed: args.headed,
  2992. };
  2993. return value;
  2994. }
  2995. async function runPostSimulationCleanup(sessionName, index, args, sharedConfig) {
  2996. if (!args.cleanupTodayPage) return null;
  2997. const runOptions = {
  2998. headed: args.headed,
  2999. autoConnect: args.autoConnect,
  3000. profile: sharedConfig.instanceProfiles[index] ?? null,
  3001. launchArgs: sharedConfig.effectiveLaunchArgs,
  3002. executablePath: sharedConfig.effectiveExecutablePath,
  3003. };
  3004. const cleanupEval = await runAgentBrowser(
  3005. sessionName,
  3006. ['eval', '--stdin'],
  3007. {
  3008. input: buildCleanupTodayPageProgram({
  3009. cleanupTodayPage: args.cleanupTodayPage,
  3010. }),
  3011. timeoutMs: 30000,
  3012. ...runOptions,
  3013. }
  3014. );
  3015. return cleanupEval?.data?.result || null;
  3016. }
  3017. function formatFailureText(reason) {
  3018. return String(reason?.stack || reason?.message || reason);
  3019. }
  3020. function classifySimulationFailure(reason) {
  3021. const text = formatFailureText(reason).toLowerCase();
  3022. if (
  3023. text.includes('checksum mismatch rtc-log detected') ||
  3024. text.includes('db-sync/checksum-mismatch') ||
  3025. text.includes(':rtc.log/checksum-mismatch')
  3026. ) {
  3027. return 'checksum_mismatch';
  3028. }
  3029. if (
  3030. text.includes('tx rejected rtc-log detected') ||
  3031. text.includes('tx-rejected warning detected') ||
  3032. text.includes('db-sync/tx-rejected') ||
  3033. text.includes(':rtc.log/tx-rejected')
  3034. ) {
  3035. return 'tx_rejected';
  3036. }
  3037. if (
  3038. text.includes('missing-entity-id warning detected') ||
  3039. text.includes('nothing found for entity id')
  3040. ) {
  3041. return 'missing_entity_id';
  3042. }
  3043. if (
  3044. text.includes('numeric-entity-id-in-non-transact-op warning detected') ||
  3045. text.includes('non-transact outliner ops contain numeric entity ids')
  3046. ) {
  3047. return 'numeric_entity_id_in_non_transact_op';
  3048. }
  3049. return 'other';
  3050. }
  3051. function buildRejectedResultEntry(sessionName, index, reason, failFastState) {
  3052. const failureType = classifySimulationFailure(reason);
  3053. const error = formatFailureText(reason);
  3054. const partialResult =
  3055. reason && typeof reason === 'object' && reason.partialResult && typeof reason.partialResult === 'object'
  3056. ? reason.partialResult
  3057. : null;
  3058. const peerCancelledByFailFast =
  3059. (failFastState?.reasonType === 'checksum_mismatch' ||
  3060. failFastState?.reasonType === 'tx_rejected' ||
  3061. failFastState?.reasonType === 'missing_entity_id' ||
  3062. failFastState?.reasonType === 'numeric_entity_id_in_non_transact_op') &&
  3063. Number.isInteger(failFastState?.sourceIndex) &&
  3064. failFastState.sourceIndex !== index;
  3065. if (peerCancelledByFailFast) {
  3066. const cancelledReason =
  3067. failFastState.reasonType === 'tx_rejected'
  3068. ? 'cancelled_due_to_peer_tx_rejected'
  3069. : (
  3070. failFastState.reasonType === 'missing_entity_id'
  3071. ? 'cancelled_due_to_peer_missing_entity_id'
  3072. : (
  3073. failFastState.reasonType === 'numeric_entity_id_in_non_transact_op'
  3074. ? 'cancelled_due_to_peer_numeric_entity_id_in_non_transact_op'
  3075. : 'cancelled_due_to_peer_checksum_mismatch'
  3076. )
  3077. );
  3078. return {
  3079. session: sessionName,
  3080. instanceIndex: index + 1,
  3081. ok: false,
  3082. cancelled: true,
  3083. cancelledReason,
  3084. peerInstanceIndex: failFastState.sourceIndex + 1,
  3085. error,
  3086. failureType: 'peer_cancelled',
  3087. result: partialResult,
  3088. };
  3089. }
  3090. return {
  3091. session: sessionName,
  3092. instanceIndex: index + 1,
  3093. ok: false,
  3094. error,
  3095. failureType,
  3096. result: partialResult,
  3097. };
  3098. }
  3099. function extractChecksumMismatchDetailsFromError(errorText) {
  3100. const text = String(errorText || '');
  3101. const marker = 'checksum mismatch rtc-log detected:';
  3102. const markerIndex = text.toLowerCase().indexOf(marker);
  3103. if (markerIndex === -1) return null;
  3104. const afterMarker = text.slice(markerIndex + marker.length);
  3105. const match = afterMarker.match(/\{[\s\S]*?\}/);
  3106. if (!match) return null;
  3107. try {
  3108. const parsed = JSON.parse(match[0]);
  3109. if (!parsed || typeof parsed !== 'object') return null;
  3110. return parsed;
  3111. } catch (_error) {
  3112. return null;
  3113. }
  3114. }
  3115. function extractTxRejectedDetailsFromError(errorText) {
  3116. const text = String(errorText || '');
  3117. const marker = 'tx rejected rtc-log detected:';
  3118. const markerIndex = text.toLowerCase().indexOf(marker);
  3119. if (markerIndex === -1) return null;
  3120. const afterMarker = text.slice(markerIndex + marker.length);
  3121. const match = afterMarker.match(/\{[\s\S]*?\}/);
  3122. if (!match) return null;
  3123. try {
  3124. const parsed = JSON.parse(match[0]);
  3125. if (!parsed || typeof parsed !== 'object') return null;
  3126. return parsed;
  3127. } catch (_error) {
  3128. return null;
  3129. }
  3130. }
  3131. function buildRunArtifact({ output, args, runContext, failFastState }) {
  3132. const safeOutput = output && typeof output === 'object' ? output : {};
  3133. const resultItems = Array.isArray(safeOutput.results) ? safeOutput.results : [];
  3134. const clients = resultItems.map((item) => {
  3135. const errorText = item?.error ? String(item.error) : null;
  3136. const mismatch = errorText ? extractChecksumMismatchDetailsFromError(errorText) : null;
  3137. const txRejected = errorText ? extractTxRejectedDetailsFromError(errorText) : null;
  3138. const rounds = Array.isArray(item?.result?.rounds)
  3139. ? item.result.rounds.map((round) => ({
  3140. round: Number(round?.round || 0),
  3141. requestedOps: Number(round?.requestedOps || 0),
  3142. executedOps: Number(round?.executedOps || 0),
  3143. errorCount: Number(round?.errorCount || 0),
  3144. requestedPlan: Array.isArray(round?.requestedPlan)
  3145. ? round.requestedPlan
  3146. : [],
  3147. opLog: Array.isArray(round?.opLog)
  3148. ? round.opLog
  3149. : [],
  3150. errors: Array.isArray(round?.errors)
  3151. ? round.errors
  3152. : [],
  3153. warnings: Array.isArray(round?.warnings)
  3154. ? round.warnings
  3155. : [],
  3156. initialDb: round?.initialDb && typeof round.initialDb === 'object'
  3157. ? round.initialDb
  3158. : null,
  3159. txCapture: round?.txCapture && typeof round.txCapture === 'object'
  3160. ? round.txCapture
  3161. : null,
  3162. consoleLogs: Array.isArray(round?.consoleLogs)
  3163. ? round.consoleLogs
  3164. : [],
  3165. wsMessages: round?.wsMessages && typeof round.wsMessages === 'object'
  3166. ? round.wsMessages
  3167. : null,
  3168. }))
  3169. : [];
  3170. return {
  3171. session: item?.session || null,
  3172. instanceIndex: Number.isInteger(item?.instanceIndex) ? item.instanceIndex : null,
  3173. ok: Boolean(item?.ok),
  3174. cancelled: item?.cancelled === true,
  3175. cancelledReason: item?.cancelledReason || null,
  3176. failureType: item?.failureType || null,
  3177. error: errorText,
  3178. mismatch,
  3179. txRejected,
  3180. requestedOps: Number(item?.result?.requestedOps || 0),
  3181. executedOps: Number(item?.result?.executedOps || 0),
  3182. errorCount: Number(item?.result?.errorCount || 0),
  3183. failedRounds: Array.isArray(item?.result?.failedRounds) ? item.result.failedRounds : [],
  3184. requestedPlan: Array.isArray(item?.result?.rounds?.[0]?.requestedPlan)
  3185. ? item.result.rounds[0].requestedPlan
  3186. : [],
  3187. opLogTail: Array.isArray(item?.result?.rounds?.[0]?.opLog)
  3188. ? item.result.rounds[0].opLog.slice(-50)
  3189. : [],
  3190. opLogSample: Array.isArray(item?.result?.rounds?.[0]?.opLogSample)
  3191. ? item.result.rounds[0].opLogSample
  3192. : [],
  3193. errors: Array.isArray(item?.result?.rounds?.[0]?.errors)
  3194. ? item.result.rounds[0].errors
  3195. : [],
  3196. rounds,
  3197. };
  3198. });
  3199. return {
  3200. createdAt: new Date().toISOString(),
  3201. runId: runContext?.runId || null,
  3202. runPrefix: runContext?.runPrefix || null,
  3203. args: args || {},
  3204. summary: {
  3205. ok: Boolean(safeOutput.ok),
  3206. instances: Number(safeOutput.instances || clients.length || 0),
  3207. successCount: Number(safeOutput.successCount || 0),
  3208. failureCount: Number(safeOutput.failureCount || 0),
  3209. },
  3210. failFast: {
  3211. triggered: Boolean(failFastState?.triggered),
  3212. sourceIndex: Number.isInteger(failFastState?.sourceIndex)
  3213. ? failFastState.sourceIndex
  3214. : null,
  3215. reasonType: failFastState?.reasonType || null,
  3216. },
  3217. mismatchCount: clients.filter((item) => item.mismatch).length,
  3218. txRejectedCount: clients.filter((item) => item.txRejected).length,
  3219. clients,
  3220. };
  3221. }
  3222. function extractReplayContext(artifact) {
  3223. const argsOverride =
  3224. artifact && typeof artifact.args === 'object' && artifact.args
  3225. ? { ...artifact.args }
  3226. : {};
  3227. const fixedPlansByInstance = new Map();
  3228. const clients = Array.isArray(artifact?.clients) ? artifact.clients : [];
  3229. for (const client of clients) {
  3230. const instanceIndex = Number(client?.instanceIndex);
  3231. if (!Number.isInteger(instanceIndex) || instanceIndex <= 0) continue;
  3232. if (!Array.isArray(client?.requestedPlan)) continue;
  3233. fixedPlansByInstance.set(instanceIndex, [...client.requestedPlan]);
  3234. }
  3235. return {
  3236. argsOverride,
  3237. fixedPlansByInstance,
  3238. };
  3239. }
  3240. async function writeRunArtifact(artifact, baseDir = DEFAULT_ARTIFACT_BASE_DIR) {
  3241. const runId = String(artifact?.runId || Date.now());
  3242. const artifactDir = path.join(baseDir, runId);
  3243. await fsPromises.mkdir(artifactDir, { recursive: true });
  3244. await fsPromises.writeFile(
  3245. path.join(artifactDir, 'artifact.json'),
  3246. JSON.stringify(artifact, null, 2),
  3247. 'utf8'
  3248. );
  3249. const clients = Array.isArray(artifact?.clients) ? artifact.clients : [];
  3250. for (let i = 0; i < clients.length; i += 1) {
  3251. const client = clients[i];
  3252. const clientIndex =
  3253. Number.isInteger(client?.instanceIndex) && client.instanceIndex > 0
  3254. ? client.instanceIndex
  3255. : i + 1;
  3256. const clientDir = path.join(artifactDir, 'clients', `client-${clientIndex}`);
  3257. await fsPromises.mkdir(clientDir, { recursive: true });
  3258. const rounds = Array.isArray(client?.rounds) ? client.rounds : [];
  3259. for (let j = 0; j < rounds.length; j += 1) {
  3260. const round = rounds[j];
  3261. const roundIndex =
  3262. Number.isInteger(round?.round) && round.round > 0
  3263. ? round.round
  3264. : j + 1;
  3265. const roundPrefix = `round-${roundIndex}`;
  3266. await fsPromises.writeFile(
  3267. path.join(clientDir, `${roundPrefix}-client-ops.json`),
  3268. JSON.stringify(
  3269. {
  3270. requestedPlan: Array.isArray(round?.requestedPlan) ? round.requestedPlan : [],
  3271. opLog: Array.isArray(round?.opLog) ? round.opLog : [],
  3272. txCapture: round?.txCapture && typeof round.txCapture === 'object'
  3273. ? round.txCapture
  3274. : null,
  3275. errors: Array.isArray(round?.errors) ? round.errors : [],
  3276. warnings: Array.isArray(round?.warnings) ? round.warnings : [],
  3277. },
  3278. null,
  3279. 2
  3280. ),
  3281. 'utf8'
  3282. );
  3283. await fsPromises.writeFile(
  3284. path.join(clientDir, `${roundPrefix}-console-log.json`),
  3285. JSON.stringify(
  3286. Array.isArray(round?.consoleLogs) ? round.consoleLogs : [],
  3287. null,
  3288. 2
  3289. ),
  3290. 'utf8'
  3291. );
  3292. await fsPromises.writeFile(
  3293. path.join(clientDir, `${roundPrefix}-ws-messages.json`),
  3294. JSON.stringify(
  3295. round?.wsMessages && typeof round.wsMessages === 'object'
  3296. ? round.wsMessages
  3297. : { installed: false, outbound: [], inbound: [] },
  3298. null,
  3299. 2
  3300. ),
  3301. 'utf8'
  3302. );
  3303. }
  3304. }
  3305. return artifactDir;
  3306. }
  3307. async function main() {
  3308. let args;
  3309. try {
  3310. args = parseArgs(process.argv.slice(2));
  3311. } catch (error) {
  3312. console.error(error.message);
  3313. console.error('\n' + usage());
  3314. process.exit(1);
  3315. return;
  3316. }
  3317. if (args.help) {
  3318. console.log(usage());
  3319. return;
  3320. }
  3321. let replayContext = {
  3322. sourceArtifactPath: null,
  3323. fixedPlansByInstance: new Map(),
  3324. };
  3325. if (args.replay) {
  3326. const replayPath = path.resolve(args.replay);
  3327. const replayContent = await fsPromises.readFile(replayPath, 'utf8');
  3328. const replayArtifact = JSON.parse(replayContent);
  3329. const extractedReplay = extractReplayContext(replayArtifact);
  3330. args = {
  3331. ...args,
  3332. ...extractedReplay.argsOverride,
  3333. replay: args.replay,
  3334. };
  3335. replayContext = {
  3336. sourceArtifactPath: replayPath,
  3337. fixedPlansByInstance: extractedReplay.fixedPlansByInstance,
  3338. };
  3339. }
  3340. const preview = {
  3341. url: args.url,
  3342. session: args.session,
  3343. instances: args.instances,
  3344. graph: args.graph,
  3345. e2ePassword: args.e2ePassword,
  3346. switchTimeoutMs: args.switchTimeoutMs,
  3347. profile: args.profile,
  3348. executablePath: args.executablePath,
  3349. autoConnect: args.autoConnect,
  3350. resetSession: args.resetSession,
  3351. ops: args.ops,
  3352. opProfile: args.opProfile,
  3353. opTimeoutMs: args.opTimeoutMs,
  3354. seed: args.seed,
  3355. replay: args.replay,
  3356. rounds: args.rounds,
  3357. undoRedoDelayMs: args.undoRedoDelayMs,
  3358. syncSettleTimeoutMs: args.syncSettleTimeoutMs,
  3359. verifyChecksum: args.verifyChecksum,
  3360. captureReplay: args.captureReplay,
  3361. cleanupTodayPage: args.cleanupTodayPage,
  3362. headed: args.headed,
  3363. };
  3364. if (args.printOnly) {
  3365. console.log(JSON.stringify(preview, null, 2));
  3366. return;
  3367. }
  3368. await spawnAndCapture('agent-browser', ['--version']);
  3369. const sessionNames = buildSessionNames(args.session, args.instances);
  3370. let effectiveProfile;
  3371. if (args.profile === 'none') {
  3372. effectiveProfile = null;
  3373. } else if (args.profile === 'auto') {
  3374. const autoName = await detectChromeProfile();
  3375. effectiveProfile = await resolveProfileArgument(autoName);
  3376. } else {
  3377. effectiveProfile = await resolveProfileArgument(args.profile);
  3378. }
  3379. const effectiveExecutablePath =
  3380. args.executablePath || (await detectChromeExecutablePath());
  3381. const effectiveLaunchArgs = effectiveProfile ? buildChromeLaunchArgs(args.url) : null;
  3382. const instanceProfiles = [];
  3383. if (args.instances <= 1 || !effectiveProfile) {
  3384. for (let i = 0; i < args.instances; i += 1) {
  3385. instanceProfiles.push(effectiveProfile);
  3386. }
  3387. } else if (looksLikePath(effectiveProfile)) {
  3388. for (let i = 0; i < args.instances; i += 1) {
  3389. instanceProfiles.push(effectiveProfile);
  3390. }
  3391. } else {
  3392. for (let i = 0; i < args.instances; i += 1) {
  3393. const isolated = await createIsolatedChromeUserDataDir(effectiveProfile, i + 1);
  3394. instanceProfiles.push(isolated);
  3395. }
  3396. }
  3397. const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
  3398. const sharedConfig = {
  3399. runId,
  3400. runPrefix: `op-sim-${runId}-`,
  3401. seed: args.seed,
  3402. replaySource: replayContext.sourceArtifactPath,
  3403. fixedPlansByInstance: replayContext.fixedPlansByInstance,
  3404. captureReplay: args.captureReplay,
  3405. effectiveProfile,
  3406. instanceProfiles,
  3407. effectiveLaunchArgs,
  3408. effectiveExecutablePath,
  3409. plan: buildSimulationOperationPlan(
  3410. Math.max(1, Math.ceil(args.ops / args.instances)),
  3411. args.opProfile
  3412. ),
  3413. };
  3414. const failFastState = {
  3415. triggered: false,
  3416. sourceIndex: null,
  3417. reasonType: null,
  3418. };
  3419. const closeOtherSessions = async (excludeIndex) => {
  3420. await Promise.all(
  3421. sessionNames.map((sessionName, index) => {
  3422. if (index === excludeIndex) return Promise.resolve();
  3423. return runAgentBrowser(sessionName, ['close'], {
  3424. autoConnect: false,
  3425. headed: false,
  3426. }).catch(() => null);
  3427. })
  3428. );
  3429. };
  3430. const tasks = sessionNames.map((sessionName, index) =>
  3431. (async () => {
  3432. try {
  3433. return await runSimulationForSession(sessionName, index, args, sharedConfig);
  3434. } catch (error) {
  3435. if (!failFastState.triggered) {
  3436. failFastState.triggered = true;
  3437. failFastState.sourceIndex = index;
  3438. failFastState.reasonType = classifySimulationFailure(error);
  3439. await closeOtherSessions(index);
  3440. }
  3441. throw error;
  3442. }
  3443. })()
  3444. );
  3445. const settled = await Promise.allSettled(tasks);
  3446. let cleanupTodayPage = null;
  3447. try {
  3448. cleanupTodayPage = await runPostSimulationCleanup(
  3449. sessionNames[0],
  3450. 0,
  3451. args,
  3452. sharedConfig
  3453. );
  3454. } catch (error) {
  3455. cleanupTodayPage = {
  3456. ok: false,
  3457. reason: String(error?.message || error),
  3458. };
  3459. }
  3460. const expectedOpsForInstance = (instanceIndex) => {
  3461. const fixedPlan =
  3462. sharedConfig.fixedPlansByInstance instanceof Map
  3463. ? sharedConfig.fixedPlansByInstance.get(instanceIndex)
  3464. : null;
  3465. const perRound = Array.isArray(fixedPlan) && fixedPlan.length > 0
  3466. ? fixedPlan.length
  3467. : sharedConfig.plan.length;
  3468. return perRound * args.rounds;
  3469. };
  3470. if (sessionNames.length === 1) {
  3471. const single = settled[0];
  3472. if (single.status === 'rejected') {
  3473. const rejected = buildRejectedResultEntry(
  3474. sessionNames[0],
  3475. 0,
  3476. single.reason,
  3477. failFastState
  3478. );
  3479. const value = {
  3480. ...(rejected.result && typeof rejected.result === 'object'
  3481. ? rejected.result
  3482. : {}),
  3483. ok: false,
  3484. error: rejected.error || formatFailureText(single.reason),
  3485. failureType: rejected.failureType || 'other',
  3486. cleanupTodayPage,
  3487. };
  3488. if (rejected.cancelled) value.cancelled = true;
  3489. if (rejected.cancelledReason) value.cancelledReason = rejected.cancelledReason;
  3490. if (Number.isInteger(rejected.peerInstanceIndex)) {
  3491. value.peerInstanceIndex = rejected.peerInstanceIndex;
  3492. }
  3493. try {
  3494. const singleOutput = {
  3495. ok: false,
  3496. instances: 1,
  3497. successCount: 0,
  3498. failureCount: 1,
  3499. results: [{
  3500. session: sessionNames[0],
  3501. instanceIndex: 1,
  3502. ok: false,
  3503. result: value,
  3504. error: rejected.error,
  3505. failureType: rejected.failureType,
  3506. cancelled: rejected.cancelled,
  3507. cancelledReason: rejected.cancelledReason,
  3508. peerInstanceIndex: rejected.peerInstanceIndex,
  3509. }],
  3510. };
  3511. const artifact = buildRunArtifact({
  3512. output: singleOutput,
  3513. args,
  3514. runContext: sharedConfig,
  3515. failFastState,
  3516. });
  3517. value.artifactDir = await writeRunArtifact(artifact);
  3518. } catch (error) {
  3519. value.artifactError = String(error?.message || error);
  3520. }
  3521. console.log(JSON.stringify(value, null, 2));
  3522. process.exitCode = 2;
  3523. return;
  3524. }
  3525. const value = single.value;
  3526. value.cleanupTodayPage = cleanupTodayPage;
  3527. try {
  3528. const singleOutput = {
  3529. ok: value.ok,
  3530. instances: 1,
  3531. successCount: value.ok ? 1 : 0,
  3532. failureCount: value.ok ? 0 : 1,
  3533. results: [{
  3534. session: sessionNames[0],
  3535. instanceIndex: 1,
  3536. ok: value.ok,
  3537. result: value,
  3538. }],
  3539. };
  3540. const artifact = buildRunArtifact({
  3541. output: singleOutput,
  3542. args,
  3543. runContext: sharedConfig,
  3544. failFastState,
  3545. });
  3546. value.artifactDir = await writeRunArtifact(artifact);
  3547. } catch (error) {
  3548. value.artifactError = String(error?.message || error);
  3549. }
  3550. console.log(JSON.stringify(value, null, 2));
  3551. if (!value.ok || value.executedOps < expectedOpsForInstance(1)) {
  3552. process.exitCode = 2;
  3553. }
  3554. return;
  3555. }
  3556. const results = settled.map((entry, idx) => {
  3557. const sessionName = sessionNames[idx];
  3558. if (entry.status === 'fulfilled') {
  3559. const value = entry.value;
  3560. const passed =
  3561. Boolean(value?.ok) &&
  3562. Number(value?.executedOps || 0) >= expectedOpsForInstance(idx + 1);
  3563. return {
  3564. session: sessionName,
  3565. instanceIndex: idx + 1,
  3566. ok: passed,
  3567. result: {
  3568. ...value,
  3569. cleanupTodayPage: idx === 0 ? cleanupTodayPage : null,
  3570. },
  3571. };
  3572. }
  3573. return buildRejectedResultEntry(sessionName, idx, entry.reason, failFastState);
  3574. });
  3575. const successCount = results.filter((item) => item.ok).length;
  3576. const output = {
  3577. ok: successCount === results.length,
  3578. instances: results.length,
  3579. successCount,
  3580. failureCount: results.length - successCount,
  3581. results,
  3582. };
  3583. try {
  3584. const artifact = buildRunArtifact({
  3585. output,
  3586. args,
  3587. runContext: sharedConfig,
  3588. failFastState,
  3589. });
  3590. output.artifactDir = await writeRunArtifact(artifact);
  3591. } catch (error) {
  3592. output.artifactError = String(error?.message || error);
  3593. }
  3594. console.log(JSON.stringify(output, null, 2));
  3595. if (!output.ok) {
  3596. process.exitCode = 2;
  3597. }
  3598. }
  3599. if (require.main === module) {
  3600. main().catch((error) => {
  3601. console.error(error.stack || String(error));
  3602. process.exit(1);
  3603. });
  3604. }
  3605. module.exports = {
  3606. parseArgs,
  3607. isRetryableAgentBrowserError,
  3608. buildCleanupTodayPageProgram,
  3609. classifySimulationFailure,
  3610. buildRejectedResultEntry,
  3611. extractChecksumMismatchDetailsFromError,
  3612. extractTxRejectedDetailsFromError,
  3613. buildRunArtifact,
  3614. extractReplayContext,
  3615. createSeededRng,
  3616. shuffleOperationPlan,
  3617. };