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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112
  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 DEFAULT_URL = 'http://localhost:3001/#/';
  9. const DEFAULT_SESSION_NAME = 'logseq-op-sim';
  10. const DEFAULT_CHROME_PROFILE = 'auto';
  11. const DEFAULT_INSTANCES = 1;
  12. const DEFAULT_OPS = 50;
  13. const DEFAULT_OP_PROFILE = 'fast';
  14. const DEFAULT_SCENARIO = 'online';
  15. const DEFAULT_OP_TIMEOUT_MS = 1000;
  16. const DEFAULT_ROUNDS = 1;
  17. const DEFAULT_UNDO_REDO_DELAY_MS = 350;
  18. const DEFAULT_HEADED = true;
  19. const DEFAULT_AUTO_CONNECT = false;
  20. const DEFAULT_RESET_SESSION = true;
  21. const DEFAULT_TARGET_GRAPH = 'db1';
  22. const DEFAULT_E2E_PASSWORD = '12345';
  23. const DEFAULT_SWITCH_GRAPH_TIMEOUT_MS = 100000;
  24. const DEFAULT_CHROME_LAUNCH_ARGS = [
  25. '--new-window',
  26. '--no-first-run',
  27. '--no-default-browser-check',
  28. ];
  29. const RENDERER_READY_TIMEOUT_MS = 30000;
  30. const RENDERER_READY_POLL_DELAY_MS = 250;
  31. const FALLBACK_PAGE_NAME = 'op-sim-scratch';
  32. const DEFAULT_VERIFY_CHECKSUM = true;
  33. const DEFAULT_CLEANUP_TODAY_PAGE = true;
  34. const DEFAULT_CAPTURE_REPLAY = true;
  35. const DEFAULT_SYNC_SETTLE_TIMEOUT_MS = 3000;
  36. const AGENT_BROWSER_ACTION_TIMEOUT_MS = 1000000;
  37. const PROCESS_TIMEOUT_MS = 1000000;
  38. const AGENT_BROWSER_RETRY_COUNT = 2;
  39. const BOOTSTRAP_EVAL_TIMEOUT_MS = 150000;
  40. const RENDERER_EVAL_BASE_TIMEOUT_MS = 30000;
  41. const DEFAULT_ARTIFACT_BASE_DIR = path.join('tmp', 'db-sync-repro');
  42. const FULL_PROFILE_OPERATION_ORDER = Object.freeze([
  43. 'add',
  44. 'save',
  45. 'inlineTag',
  46. 'emptyInlineTag',
  47. 'pageReference',
  48. 'blockReference',
  49. 'propertySet',
  50. 'batchSetProperty',
  51. 'propertyValueDelete',
  52. 'copyPaste',
  53. 'copyPasteTreeToEmptyTarget',
  54. 'templateApply',
  55. 'move',
  56. 'moveUpDown',
  57. 'indent',
  58. 'outdent',
  59. 'delete',
  60. 'propertyRemove',
  61. 'undo',
  62. 'redo',
  63. ]);
  64. const FAST_PROFILE_OPERATION_ORDER = Object.freeze([
  65. 'add',
  66. 'save',
  67. 'inlineTag',
  68. 'emptyInlineTag',
  69. 'pageReference',
  70. 'blockReference',
  71. 'propertySet',
  72. 'batchSetProperty',
  73. 'move',
  74. 'delete',
  75. 'indent',
  76. 'outdent',
  77. 'moveUpDown',
  78. 'templateApply',
  79. 'propertyValueDelete',
  80. 'add',
  81. 'move',
  82. ]);
  83. const ALL_OUTLINER_OP_COVERAGE_OPS = Object.freeze([
  84. 'save-block',
  85. 'insert-blocks',
  86. 'apply-template',
  87. 'delete-blocks',
  88. 'move-blocks',
  89. 'move-blocks-up-down',
  90. 'indent-outdent-blocks',
  91. 'upsert-property',
  92. 'set-block-property',
  93. 'remove-block-property',
  94. 'delete-property-value',
  95. 'batch-delete-property-value',
  96. 'create-property-text-block',
  97. 'collapse-expand-block-property',
  98. 'batch-set-property',
  99. 'batch-remove-property',
  100. 'class-add-property',
  101. 'class-remove-property',
  102. 'upsert-closed-value',
  103. 'delete-closed-value',
  104. 'add-existing-values-to-closed-values',
  105. 'batch-import-edn',
  106. 'transact',
  107. 'create-page',
  108. 'rename-page',
  109. 'delete-page',
  110. 'recycle-delete-permanently',
  111. 'toggle-reaction',
  112. 'restore-recycled',
  113. ]);
  114. function usage() {
  115. return [
  116. 'Usage: node scripts/sync-open-chrome-tab-simulate.cjs [options]',
  117. '',
  118. 'Options:',
  119. ` --url <url> URL to open (default: ${DEFAULT_URL})`,
  120. ` --session <name> agent-browser session name (default: ${DEFAULT_SESSION_NAME})`,
  121. ` --instances <n> Number of concurrent browser instances (default: ${DEFAULT_INSTANCES})`,
  122. ` --graph <name> Graph name to switch/download before ops (default: ${DEFAULT_TARGET_GRAPH})`,
  123. ` --e2e-password <text> Password for E2EE modal if prompted (default: ${DEFAULT_E2E_PASSWORD})`,
  124. ' --profile <name|path|auto|none> Chrome profile to reuse login state (default: auto)',
  125. ' auto = prefer Default, then logseq.com',
  126. ' none = do not pass --profile to agent-browser (isolated profile)',
  127. ' profile labels are mapped to Chrome profile names',
  128. ' --executable-path <path> Chrome executable path (default: auto-detect system Chrome)',
  129. ' --auto-connect Enable auto-connect to an already running Chrome instance',
  130. ' --no-auto-connect Disable auto-connect to a running Chrome instance',
  131. ' --no-reset-session Do not close the target agent-browser session before starting',
  132. ` --switch-timeout-ms <n> Timeout for graph switch/download bootstrap (default: ${DEFAULT_SWITCH_GRAPH_TIMEOUT_MS})`,
  133. ` --ops <n> Total operations across all instances per round (must be >= 1, default: ${DEFAULT_OPS})`,
  134. ` --op-profile <name> Operation profile: fast|full (default: ${DEFAULT_OP_PROFILE})`,
  135. ` --scenario <name> Simulation scenario: online|offline (default: ${DEFAULT_SCENARIO})`,
  136. ` --op-timeout-ms <n> Timeout per operation in renderer (default: ${DEFAULT_OP_TIMEOUT_MS})`,
  137. ' --seed <text|number> Deterministic seed for operation ordering/jitter',
  138. ' --replay <artifact.json> Replay a prior captured artifact run',
  139. ` --rounds <n> Number of operation rounds per instance (default: ${DEFAULT_ROUNDS})`,
  140. ` --undo-redo-delay-ms <n> Wait time after undo/redo command (default: ${DEFAULT_UNDO_REDO_DELAY_MS})`,
  141. ` --sync-settle-timeout-ms <n> Timeout waiting for local/remote tx to settle before checksum verify (default: ${DEFAULT_SYNC_SETTLE_TIMEOUT_MS})`,
  142. ' --verify-checksum Run dev checksum diagnostics after each round (default: enabled)',
  143. ' --no-verify-checksum Skip post-round checksum diagnostics',
  144. ' --capture-replay Capture initial DB + per-op tx stream for local replay (default: enabled)',
  145. ' --no-capture-replay Skip replay capture payloads',
  146. ' --cleanup-today-page Delete today page after simulation (default: enabled)',
  147. ' --no-cleanup-today-page Keep today page unchanged after simulation',
  148. ' --headless Run agent-browser in headless mode',
  149. ' --print-only Print parsed args only, do not run simulation',
  150. ' -h, --help Show this message',
  151. ].join('\n');
  152. }
  153. function parsePositiveInteger(value, flagName) {
  154. const parsed = Number.parseInt(value, 10);
  155. if (!Number.isInteger(parsed) || parsed <= 0) {
  156. throw new Error(`${flagName} must be a positive integer`);
  157. }
  158. return parsed;
  159. }
  160. function parseNonNegativeInteger(value, flagName) {
  161. const parsed = Number.parseInt(value, 10);
  162. if (!Number.isInteger(parsed) || parsed < 0) {
  163. throw new Error(`${flagName} must be a non-negative integer`);
  164. }
  165. return parsed;
  166. }
  167. function parseArgs(argv) {
  168. const result = {
  169. url: DEFAULT_URL,
  170. session: DEFAULT_SESSION_NAME,
  171. instances: DEFAULT_INSTANCES,
  172. graph: DEFAULT_TARGET_GRAPH,
  173. e2ePassword: DEFAULT_E2E_PASSWORD,
  174. profile: DEFAULT_CHROME_PROFILE,
  175. executablePath: null,
  176. autoConnect: DEFAULT_AUTO_CONNECT,
  177. resetSession: DEFAULT_RESET_SESSION,
  178. switchTimeoutMs: DEFAULT_SWITCH_GRAPH_TIMEOUT_MS,
  179. ops: DEFAULT_OPS,
  180. opProfile: DEFAULT_OP_PROFILE,
  181. scenario: DEFAULT_SCENARIO,
  182. opTimeoutMs: DEFAULT_OP_TIMEOUT_MS,
  183. seed: null,
  184. replay: null,
  185. rounds: DEFAULT_ROUNDS,
  186. undoRedoDelayMs: DEFAULT_UNDO_REDO_DELAY_MS,
  187. syncSettleTimeoutMs: DEFAULT_SYNC_SETTLE_TIMEOUT_MS,
  188. verifyChecksum: DEFAULT_VERIFY_CHECKSUM,
  189. captureReplay: DEFAULT_CAPTURE_REPLAY,
  190. cleanupTodayPage: DEFAULT_CLEANUP_TODAY_PAGE,
  191. headed: DEFAULT_HEADED,
  192. printOnly: false,
  193. };
  194. for (let i = 0; i < argv.length; i += 1) {
  195. const arg = argv[i];
  196. if (arg === '--help' || arg === '-h') {
  197. return { ...result, help: true };
  198. }
  199. if (arg === '--print-only') {
  200. result.printOnly = true;
  201. continue;
  202. }
  203. if (arg === '--headless') {
  204. result.headed = false;
  205. continue;
  206. }
  207. if (arg === '--verify-checksum') {
  208. result.verifyChecksum = true;
  209. continue;
  210. }
  211. if (arg === '--no-verify-checksum') {
  212. result.verifyChecksum = false;
  213. continue;
  214. }
  215. if (arg === '--cleanup-today-page') {
  216. result.cleanupTodayPage = true;
  217. continue;
  218. }
  219. if (arg === '--no-cleanup-today-page') {
  220. result.cleanupTodayPage = false;
  221. continue;
  222. }
  223. if (arg === '--capture-replay') {
  224. result.captureReplay = true;
  225. continue;
  226. }
  227. if (arg === '--no-capture-replay') {
  228. result.captureReplay = false;
  229. continue;
  230. }
  231. if (arg === '--no-auto-connect') {
  232. result.autoConnect = false;
  233. continue;
  234. }
  235. if (arg === '--auto-connect') {
  236. result.autoConnect = true;
  237. continue;
  238. }
  239. if (arg === '--no-reset-session') {
  240. result.resetSession = false;
  241. continue;
  242. }
  243. const next = argv[i + 1];
  244. if (arg === '--url') {
  245. if (typeof next !== 'string' || next.length === 0) {
  246. throw new Error('--url must be a non-empty string');
  247. }
  248. result.url = next;
  249. i += 1;
  250. continue;
  251. }
  252. if (arg === '--session') {
  253. if (typeof next !== 'string' || next.length === 0) {
  254. throw new Error('--session must be a non-empty string');
  255. }
  256. result.session = next;
  257. i += 1;
  258. continue;
  259. }
  260. if (arg === '--graph') {
  261. if (typeof next !== 'string' || next.length === 0) {
  262. throw new Error('--graph must be a non-empty string');
  263. }
  264. result.graph = next;
  265. i += 1;
  266. continue;
  267. }
  268. if (arg === '--e2e-password') {
  269. if (typeof next !== 'string' || next.length === 0) {
  270. throw new Error('--e2e-password must be a non-empty string');
  271. }
  272. result.e2ePassword = next;
  273. i += 1;
  274. continue;
  275. }
  276. if (arg === '--instances') {
  277. result.instances = parsePositiveInteger(next, '--instances');
  278. i += 1;
  279. continue;
  280. }
  281. if (arg === '--profile') {
  282. if (typeof next !== 'string' || next.length === 0) {
  283. throw new Error('--profile must be a non-empty string');
  284. }
  285. result.profile = next;
  286. i += 1;
  287. continue;
  288. }
  289. if (arg === '--executable-path') {
  290. if (typeof next !== 'string' || next.length === 0) {
  291. throw new Error('--executable-path must be a non-empty string');
  292. }
  293. result.executablePath = next;
  294. i += 1;
  295. continue;
  296. }
  297. if (arg === '--ops') {
  298. result.ops = parsePositiveInteger(next, '--ops');
  299. i += 1;
  300. continue;
  301. }
  302. if (arg === '--op-profile') {
  303. if (typeof next !== 'string' || next.length === 0) {
  304. throw new Error('--op-profile must be a non-empty string');
  305. }
  306. const normalized = next.toLowerCase();
  307. if (normalized !== 'fast' && normalized !== 'full') {
  308. throw new Error('--op-profile must be one of: fast, full');
  309. }
  310. result.opProfile = normalized;
  311. i += 1;
  312. continue;
  313. }
  314. if (arg === '--op-timeout-ms') {
  315. result.opTimeoutMs = parsePositiveInteger(next, '--op-timeout-ms');
  316. i += 1;
  317. continue;
  318. }
  319. if (arg === '--scenario') {
  320. if (typeof next !== 'string' || next.length === 0) {
  321. throw new Error('--scenario must be a non-empty string');
  322. }
  323. const normalized = next.toLowerCase();
  324. if (normalized !== 'online' && normalized !== 'offline') {
  325. throw new Error('--scenario must be one of: online, offline');
  326. }
  327. result.scenario = normalized;
  328. i += 1;
  329. continue;
  330. }
  331. if (arg === '--seed') {
  332. if (typeof next !== 'string' || next.length === 0) {
  333. throw new Error('--seed must be a non-empty string');
  334. }
  335. result.seed = next;
  336. i += 1;
  337. continue;
  338. }
  339. if (arg === '--replay') {
  340. if (typeof next !== 'string' || next.length === 0) {
  341. throw new Error('--replay must be a non-empty path');
  342. }
  343. result.replay = next;
  344. i += 1;
  345. continue;
  346. }
  347. if (arg === '--rounds') {
  348. result.rounds = parsePositiveInteger(next, '--rounds');
  349. i += 1;
  350. continue;
  351. }
  352. if (arg === '--undo-redo-delay-ms') {
  353. result.undoRedoDelayMs = parseNonNegativeInteger(next, '--undo-redo-delay-ms');
  354. i += 1;
  355. continue;
  356. }
  357. if (arg === '--sync-settle-timeout-ms') {
  358. result.syncSettleTimeoutMs = parsePositiveInteger(next, '--sync-settle-timeout-ms');
  359. i += 1;
  360. continue;
  361. }
  362. if (arg === '--switch-timeout-ms') {
  363. result.switchTimeoutMs = parsePositiveInteger(next, '--switch-timeout-ms');
  364. i += 1;
  365. continue;
  366. }
  367. throw new Error(`Unknown argument: ${arg}`);
  368. }
  369. if (result.ops < 1) {
  370. throw new Error('--ops must be at least 1');
  371. }
  372. if (result.rounds < 1) {
  373. throw new Error('--rounds must be at least 1');
  374. }
  375. return result;
  376. }
  377. function spawnAndCapture(cmd, args, options = {}) {
  378. const {
  379. input,
  380. timeoutMs = PROCESS_TIMEOUT_MS,
  381. env = process.env,
  382. } = options;
  383. return new Promise((resolve, reject) => {
  384. const child = spawn(cmd, args, {
  385. stdio: ['pipe', 'pipe', 'pipe'],
  386. env,
  387. });
  388. let stdout = '';
  389. let stderr = '';
  390. let timedOut = false;
  391. const timer = setTimeout(() => {
  392. timedOut = true;
  393. child.kill('SIGTERM');
  394. }, timeoutMs);
  395. child.stdout.on('data', (payload) => {
  396. stdout += payload.toString();
  397. });
  398. child.stderr.on('data', (payload) => {
  399. stderr += payload.toString();
  400. });
  401. child.once('error', (error) => {
  402. clearTimeout(timer);
  403. reject(error);
  404. });
  405. child.once('exit', (code) => {
  406. clearTimeout(timer);
  407. if (timedOut) {
  408. reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`));
  409. return;
  410. }
  411. if (code === 0) {
  412. resolve({ code, stdout, stderr });
  413. return;
  414. }
  415. const detail = stderr.trim() || stdout.trim();
  416. reject(
  417. new Error(
  418. `Command failed: ${cmd} ${args.join(' ')} (exit ${code})` +
  419. (detail ? `\n${detail}` : '')
  420. )
  421. );
  422. });
  423. if (typeof input === 'string') {
  424. child.stdin.write(input);
  425. }
  426. child.stdin.end();
  427. });
  428. }
  429. function parseJsonOutput(output) {
  430. const text = output.trim();
  431. if (!text) {
  432. throw new Error('Expected JSON output from agent-browser but got empty output');
  433. }
  434. try {
  435. return JSON.parse(text);
  436. } catch (_error) {
  437. const lines = text.split(/\r?\n/).filter(Boolean);
  438. const lastLine = lines[lines.length - 1];
  439. try {
  440. return JSON.parse(lastLine);
  441. } catch (error) {
  442. throw new Error('Failed to parse JSON output from agent-browser: ' + String(error.message || error));
  443. }
  444. }
  445. }
  446. function sleep(ms) {
  447. return new Promise((resolve) => setTimeout(resolve, ms));
  448. }
  449. function hashSeed(input) {
  450. const text = String(input ?? '');
  451. let hash = 2166136261;
  452. for (let i = 0; i < text.length; i += 1) {
  453. hash ^= text.charCodeAt(i);
  454. hash = Math.imul(hash, 16777619);
  455. }
  456. return hash >>> 0;
  457. }
  458. function createSeededRng(seedInput) {
  459. let state = hashSeed(seedInput);
  460. if (state === 0) {
  461. state = 0x9e3779b9;
  462. }
  463. return () => {
  464. state = (state + 0x6D2B79F5) >>> 0;
  465. let payload = state;
  466. payload = Math.imul(payload ^ (payload >>> 15), payload | 1);
  467. payload ^= payload + Math.imul(payload ^ (payload >>> 7), payload | 61);
  468. return ((payload ^ (payload >>> 14)) >>> 0) / 4294967296;
  469. };
  470. }
  471. function deriveSeed(baseSeed, ...parts) {
  472. return hashSeed([String(baseSeed ?? ''), ...parts.map((it) => String(it))].join('::'));
  473. }
  474. function sanitizeForFilename(value) {
  475. return String(value || 'default').replace(/[^a-zA-Z0-9._-]+/g, '-');
  476. }
  477. async function pathExists(targetPath) {
  478. try {
  479. await fsPromises.access(targetPath);
  480. return true;
  481. } catch (_error) {
  482. return false;
  483. }
  484. }
  485. async function copyIfExists(sourcePath, destPath) {
  486. if (!(await pathExists(sourcePath))) return false;
  487. await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
  488. await fsPromises.cp(sourcePath, destPath, {
  489. force: true,
  490. recursive: true,
  491. });
  492. return true;
  493. }
  494. async function detectChromeUserDataRoot() {
  495. const home = os.homedir();
  496. const candidates = [];
  497. if (process.platform === 'darwin') {
  498. candidates.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'));
  499. } else if (process.platform === 'win32') {
  500. const localAppData = process.env.LOCALAPPDATA;
  501. if (localAppData) {
  502. candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data'));
  503. }
  504. } else {
  505. candidates.push(path.join(home, '.config', 'google-chrome'));
  506. candidates.push(path.join(home, '.config', 'chromium'));
  507. }
  508. for (const candidate of candidates) {
  509. if (await pathExists(candidate)) return candidate;
  510. }
  511. return null;
  512. }
  513. async function createIsolatedChromeUserDataDir(sourceProfileName, instanceIndex) {
  514. const sourceRoot = await detectChromeUserDataRoot();
  515. if (!sourceRoot) {
  516. throw new Error('Cannot find Chrome user data root to clone auth profile');
  517. }
  518. const sourceProfileDir = path.join(sourceRoot, sourceProfileName);
  519. if (!(await pathExists(sourceProfileDir))) {
  520. throw new Error(`Cannot find Chrome profile directory to clone: ${sourceProfileDir}`);
  521. }
  522. const targetRoot = path.join(
  523. os.tmpdir(),
  524. `logseq-op-sim-user-data-${sanitizeForFilename(sourceProfileName)}-${instanceIndex}`
  525. );
  526. const targetDefaultProfileDir = path.join(targetRoot, 'Default');
  527. await fsPromises.rm(targetRoot, { recursive: true, force: true });
  528. await fsPromises.mkdir(targetDefaultProfileDir, { recursive: true });
  529. await copyIfExists(path.join(sourceRoot, 'Local State'), path.join(targetRoot, 'Local State'));
  530. const entries = [
  531. 'Network',
  532. 'Cookies',
  533. 'Local Storage',
  534. 'Session Storage',
  535. 'IndexedDB',
  536. 'WebStorage',
  537. 'Preferences',
  538. 'Secure Preferences',
  539. ];
  540. for (const entry of entries) {
  541. await copyIfExists(
  542. path.join(sourceProfileDir, entry),
  543. path.join(targetDefaultProfileDir, entry)
  544. );
  545. }
  546. return targetRoot;
  547. }
  548. function buildChromeLaunchArgs(url) {
  549. return [
  550. `--app=${url}`,
  551. ...DEFAULT_CHROME_LAUNCH_ARGS,
  552. ];
  553. }
  554. function isRetryableAgentBrowserError(error) {
  555. const message = String(error?.message || error || '');
  556. return (
  557. /daemon may be busy or unresponsive/i.test(message) ||
  558. /resource temporarily unavailable/i.test(message) ||
  559. /os error 35/i.test(message) ||
  560. /EAGAIN/i.test(message) ||
  561. /inspected target navigated or closed/i.test(message) ||
  562. /execution context was destroyed/i.test(message) ||
  563. /cannot find context with specified id/i.test(message) ||
  564. /target closed/i.test(message) ||
  565. /session closed/i.test(message) ||
  566. /cdp command timed out/i.test(message) ||
  567. /cdp response channel closed/i.test(message) ||
  568. /operation timed out\. the page may still be loading/i.test(message)
  569. );
  570. }
  571. async function listChromeProfiles() {
  572. try {
  573. const { stdout } = await spawnAndCapture('agent-browser', ['profiles']);
  574. const lines = stdout.split(/\r?\n/);
  575. const profiles = [];
  576. for (const line of lines) {
  577. const match = line.match(/^\s+(.+?)\s+\((.+?)\)\s*$/);
  578. if (!match) continue;
  579. profiles.push({
  580. profile: match[1].trim(),
  581. label: match[2].trim(),
  582. });
  583. }
  584. return profiles;
  585. } catch (_error) {
  586. return [];
  587. }
  588. }
  589. async function detectChromeProfile() {
  590. const profiles = await listChromeProfiles();
  591. if (profiles.length > 0) {
  592. const defaultProfile = profiles.find((item) => item.profile === 'Default');
  593. if (defaultProfile) return defaultProfile.profile;
  594. return profiles[0].profile;
  595. }
  596. return 'Default';
  597. }
  598. async function detectChromeExecutablePath() {
  599. const candidates = [
  600. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  601. `${process.env.HOME || ''}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
  602. '/usr/bin/google-chrome',
  603. '/usr/bin/google-chrome-stable',
  604. '/usr/bin/chromium',
  605. '/usr/bin/chromium-browser',
  606. ].filter(Boolean);
  607. for (const candidate of candidates) {
  608. try {
  609. await fsPromises.access(candidate, fs.constants.X_OK);
  610. return candidate;
  611. } catch (_error) {
  612. // keep trying
  613. }
  614. }
  615. return null;
  616. }
  617. function expandHome(inputPath) {
  618. if (typeof inputPath !== 'string') return inputPath;
  619. if (!inputPath.startsWith('~')) return inputPath;
  620. return path.join(os.homedir(), inputPath.slice(1));
  621. }
  622. function looksLikePath(value) {
  623. return value.includes('/') || value.includes('\\') || value.startsWith('~') || value.startsWith('.');
  624. }
  625. async function resolveProfileArgument(profile) {
  626. if (!profile) return null;
  627. if (looksLikePath(profile)) {
  628. return expandHome(profile);
  629. }
  630. let profileName = profile;
  631. const profiles = await listChromeProfiles();
  632. if (profiles.length > 0) {
  633. const byLabel = profiles.find((item) => item.label.toLowerCase() === profile.toLowerCase());
  634. if (byLabel) {
  635. profileName = byLabel.profile;
  636. }
  637. }
  638. return profileName;
  639. }
  640. async function runAgentBrowser(session, commandArgs, options = {}) {
  641. const {
  642. retries = AGENT_BROWSER_RETRY_COUNT,
  643. ...commandOptions
  644. } = options;
  645. const env = {
  646. ...process.env,
  647. AGENT_BROWSER_DEFAULT_TIMEOUT: String(AGENT_BROWSER_ACTION_TIMEOUT_MS),
  648. };
  649. const globalFlags = ['--session', session];
  650. if (commandOptions.headed) {
  651. globalFlags.push('--headed');
  652. }
  653. if (commandOptions.autoConnect) {
  654. globalFlags.push('--auto-connect');
  655. }
  656. if (commandOptions.profile) {
  657. globalFlags.push('--profile', commandOptions.profile);
  658. }
  659. if (commandOptions.state) {
  660. globalFlags.push('--state', commandOptions.state);
  661. }
  662. if (Array.isArray(commandOptions.launchArgs) && commandOptions.launchArgs.length > 0) {
  663. globalFlags.push('--args', commandOptions.launchArgs.join(','));
  664. }
  665. if (commandOptions.executablePath) {
  666. globalFlags.push('--executable-path', commandOptions.executablePath);
  667. }
  668. let lastError = null;
  669. for (let attempt = 0; attempt <= retries; attempt += 1) {
  670. try {
  671. const { stdout, stderr } = await spawnAndCapture(
  672. 'agent-browser',
  673. [...globalFlags, ...commandArgs, '--json'],
  674. {
  675. ...commandOptions,
  676. env,
  677. }
  678. );
  679. const parsed = parseJsonOutput(stdout);
  680. if (!parsed || parsed.success !== true) {
  681. const fallback =
  682. String(parsed?.error || '').trim() ||
  683. stderr.trim() ||
  684. stdout.trim();
  685. throw new Error('agent-browser command failed: ' + (fallback || 'unknown error'));
  686. }
  687. return parsed;
  688. } catch (error) {
  689. lastError = error;
  690. if (attempt >= retries || !isRetryableAgentBrowserError(error)) {
  691. throw error;
  692. }
  693. await sleep((attempt + 1) * 250);
  694. }
  695. }
  696. throw lastError || new Error('agent-browser command failed');
  697. }
  698. function urlMatchesTarget(candidate, targetUrl) {
  699. if (typeof candidate !== 'string' || typeof targetUrl !== 'string') return false;
  700. if (candidate === targetUrl) return true;
  701. if (candidate.startsWith(targetUrl)) return true;
  702. try {
  703. const candidateUrl = new URL(candidate);
  704. const target = new URL(targetUrl);
  705. return (
  706. candidateUrl.origin === target.origin &&
  707. candidateUrl.pathname === target.pathname
  708. );
  709. } catch (_error) {
  710. return false;
  711. }
  712. }
  713. async function ensureActiveTabOnTargetUrl(session, targetUrl, runOptions) {
  714. const currentUrlResult = await runAgentBrowser(session, ['get', 'url'], runOptions);
  715. const currentUrl = currentUrlResult?.data?.url;
  716. if (urlMatchesTarget(currentUrl, targetUrl)) {
  717. return;
  718. }
  719. const tabList = await runAgentBrowser(session, ['tab', 'list'], runOptions);
  720. const tabs = Array.isArray(tabList?.data?.tabs) ? tabList.data.tabs : [];
  721. const matchedTab = tabs.find((tab) => urlMatchesTarget(tab?.url, targetUrl));
  722. if (matchedTab && Number.isInteger(matchedTab.index)) {
  723. await runAgentBrowser(session, ['tab', String(matchedTab.index)], runOptions);
  724. return;
  725. }
  726. const created = await runAgentBrowser(session, ['tab', 'new', targetUrl], runOptions);
  727. const createdIndex = created?.data?.index;
  728. if (Number.isInteger(createdIndex)) {
  729. await runAgentBrowser(session, ['tab', String(createdIndex)], runOptions);
  730. }
  731. }
  732. function buildRendererProgram(config) {
  733. return `(() => (async () => {
  734. const config = ${JSON.stringify(config)};
  735. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  736. const createSeededRng = (seedInput) => {
  737. const text = String(seedInput ?? '');
  738. let hash = 2166136261;
  739. for (let i = 0; i < text.length; i += 1) {
  740. hash ^= text.charCodeAt(i);
  741. hash = Math.imul(hash, 16777619);
  742. }
  743. let state = hash >>> 0;
  744. if (state === 0) state = 0x9e3779b9;
  745. return () => {
  746. state = (state + 0x6D2B79F5) >>> 0;
  747. let payload = state;
  748. payload = Math.imul(payload ^ (payload >>> 15), payload | 1);
  749. payload ^= payload + Math.imul(payload ^ (payload >>> 7), payload | 61);
  750. return ((payload ^ (payload >>> 14)) >>> 0) / 4294967296;
  751. };
  752. };
  753. const nextRandom = createSeededRng(config.seed);
  754. const randomItem = (items) => items[Math.floor(nextRandom() * items.length)];
  755. const shuffle = (items) => {
  756. const arr = Array.isArray(items) ? [...items] : [];
  757. for (let i = arr.length - 1; i > 0; i -= 1) {
  758. const j = Math.floor(nextRandom() * (i + 1));
  759. const tmp = arr[i];
  760. arr[i] = arr[j];
  761. arr[j] = tmp;
  762. }
  763. return arr;
  764. };
  765. const describeError = (error) => String(error?.message || error);
  766. const asPageName = (pageLike) => {
  767. if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
  768. if (!pageLike || typeof pageLike !== 'object') return null;
  769. if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
  770. if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
  771. if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
  772. return null;
  773. };
  774. const expectedOutlinerOps = ${JSON.stringify(ALL_OUTLINER_OP_COVERAGE_OPS)};
  775. const waitForEditorReady = async () => {
  776. const deadline = Date.now() + config.readyTimeoutMs;
  777. let lastError = null;
  778. while (Date.now() < deadline) {
  779. try {
  780. if (
  781. globalThis.logseq?.api &&
  782. typeof logseq.api.get_current_block === 'function' &&
  783. (
  784. typeof logseq.api.get_current_page === 'function' ||
  785. typeof logseq.api.get_today_page === 'function'
  786. ) &&
  787. typeof logseq.api.append_block_in_page === 'function'
  788. ) {
  789. return;
  790. }
  791. } catch (error) {
  792. lastError = error;
  793. }
  794. await sleep(config.readyPollDelayMs);
  795. }
  796. if (lastError) {
  797. throw new Error('Logseq editor readiness timed out: ' + describeError(lastError));
  798. }
  799. throw new Error('Logseq editor readiness timed out: logseq.api is unavailable');
  800. };
  801. const runPrefix =
  802. typeof config.runPrefix === 'string' && config.runPrefix.length > 0
  803. ? config.runPrefix
  804. : config.markerPrefix;
  805. const checksumWarningToken = 'db-sync/checksum-mismatch';
  806. const txRejectedWarningToken = 'db-sync/tx-rejected';
  807. const missingEntityWarningToken = 'nothing found for entity id';
  808. const applyRemoteTxWarningToken = 'frontend.worker.sync.handle-message/apply-remote-tx';
  809. const numericEntityIdWarningToken = 'non-transact outliner ops contain numeric entity ids';
  810. const checksumWarningTokenLower = checksumWarningToken.toLowerCase();
  811. const txRejectedWarningTokenLower = txRejectedWarningToken.toLowerCase();
  812. const missingEntityWarningTokenLower = missingEntityWarningToken.toLowerCase();
  813. const applyRemoteTxWarningTokenLower = applyRemoteTxWarningToken.toLowerCase();
  814. const numericEntityIdWarningTokenLower = numericEntityIdWarningToken.toLowerCase();
  815. const fatalWarningStateKey = '__logseqOpFatalWarnings';
  816. const fatalWarningPatchKey = '__logseqOpFatalWarningPatchInstalled';
  817. const consoleCaptureStateKey = '__logseqOpConsoleCaptureStore';
  818. const wsCaptureStateKey = '__logseqOpWsCaptureStore';
  819. const wsCapturePatchKey = '__logseqOpWsCapturePatchInstalled';
  820. const MAX_DIAGNOSTIC_EVENTS = 3000;
  821. const runStartedAtMs = Date.now();
  822. const chooseRunnableOperation = (requestedOperation, operableCount) => {
  823. if (
  824. requestedOperation === 'move' ||
  825. requestedOperation === 'delete' ||
  826. requestedOperation === 'indent' ||
  827. requestedOperation === 'moveUpDown'
  828. ) {
  829. return operableCount >= 2 ? requestedOperation : 'add';
  830. }
  831. if (
  832. requestedOperation === 'propertySet' ||
  833. requestedOperation === 'batchSetProperty' ||
  834. requestedOperation === 'propertyRemove' ||
  835. requestedOperation === 'propertyValueDelete'
  836. ) {
  837. return operableCount >= 1 ? requestedOperation : 'add';
  838. }
  839. if (
  840. requestedOperation === 'inlineTag' ||
  841. requestedOperation === 'emptyInlineTag' ||
  842. requestedOperation === 'pageReference' ||
  843. requestedOperation === 'blockReference' ||
  844. requestedOperation === 'templateApply'
  845. ) {
  846. return operableCount >= 1 ? requestedOperation : 'add';
  847. }
  848. if (
  849. requestedOperation === 'copyPaste' ||
  850. requestedOperation === 'copyPasteTreeToEmptyTarget'
  851. ) {
  852. return operableCount >= 1 ? requestedOperation : 'add';
  853. }
  854. return requestedOperation;
  855. };
  856. const stringifyConsoleArg = (value) => {
  857. if (typeof value === 'string') return value;
  858. try {
  859. return JSON.stringify(value);
  860. } catch (_error) {
  861. return String(value);
  862. }
  863. };
  864. const pushBounded = (target, value, max = MAX_DIAGNOSTIC_EVENTS) => {
  865. if (!Array.isArray(target)) return;
  866. target.push(value);
  867. if (target.length > max) {
  868. target.splice(0, target.length - max);
  869. }
  870. };
  871. const consoleCaptureStore =
  872. window[consoleCaptureStateKey] && typeof window[consoleCaptureStateKey] === 'object'
  873. ? window[consoleCaptureStateKey]
  874. : {};
  875. window[consoleCaptureStateKey] = consoleCaptureStore;
  876. const consoleCaptureEntry = Array.isArray(consoleCaptureStore[config.markerPrefix])
  877. ? consoleCaptureStore[config.markerPrefix]
  878. : [];
  879. consoleCaptureStore[config.markerPrefix] = consoleCaptureEntry;
  880. const wsCaptureStore =
  881. window[wsCaptureStateKey] && typeof window[wsCaptureStateKey] === 'object'
  882. ? window[wsCaptureStateKey]
  883. : {};
  884. window[wsCaptureStateKey] = wsCaptureStore;
  885. const wsCaptureEntry =
  886. wsCaptureStore[config.markerPrefix] && typeof wsCaptureStore[config.markerPrefix] === 'object'
  887. ? wsCaptureStore[config.markerPrefix]
  888. : { outbound: [], inbound: [], installed: false, installReason: null };
  889. wsCaptureStore[config.markerPrefix] = wsCaptureEntry;
  890. const installFatalWarningTrap = () => {
  891. const warningList = Array.isArray(window[fatalWarningStateKey])
  892. ? window[fatalWarningStateKey]
  893. : [];
  894. window[fatalWarningStateKey] = warningList;
  895. if (window[fatalWarningPatchKey]) return;
  896. window[fatalWarningPatchKey] = true;
  897. const trapMethod = (method) => {
  898. const original = console[method];
  899. if (typeof original !== 'function') return;
  900. console[method] = (...args) => {
  901. try {
  902. const text = args.map(stringifyConsoleArg).join(' ');
  903. pushBounded(consoleCaptureEntry, {
  904. level: method,
  905. text,
  906. createdAt: Date.now(),
  907. });
  908. const textLower = text.toLowerCase();
  909. if (
  910. textLower.includes(checksumWarningTokenLower) ||
  911. textLower.includes(txRejectedWarningTokenLower) ||
  912. textLower.includes(numericEntityIdWarningTokenLower) ||
  913. (
  914. textLower.includes(missingEntityWarningTokenLower) &&
  915. textLower.includes(applyRemoteTxWarningTokenLower)
  916. )
  917. ) {
  918. const kind = textLower.includes(checksumWarningTokenLower)
  919. ? 'checksum_mismatch'
  920. : (
  921. textLower.includes(txRejectedWarningTokenLower)
  922. ? 'tx_rejected'
  923. : (
  924. textLower.includes(numericEntityIdWarningTokenLower)
  925. ? 'numeric_entity_id_in_non_transact_op'
  926. : 'missing_entity_id'
  927. )
  928. );
  929. warningList.push({
  930. kind,
  931. level: method,
  932. text,
  933. createdAt: Date.now(),
  934. });
  935. }
  936. } catch (_error) {
  937. // noop
  938. }
  939. return original.apply(console, args);
  940. };
  941. };
  942. trapMethod('warn');
  943. trapMethod('error');
  944. trapMethod('log');
  945. };
  946. const toWsText = (value) => {
  947. if (typeof value === 'string') return value.slice(0, 4000);
  948. if (value instanceof ArrayBuffer) {
  949. return '[ArrayBuffer byteLength=' + value.byteLength + ']';
  950. }
  951. if (typeof Blob !== 'undefined' && value instanceof Blob) {
  952. return '[Blob size=' + value.size + ']';
  953. }
  954. try {
  955. return JSON.stringify(value).slice(0, 4000);
  956. } catch (_error) {
  957. return String(value).slice(0, 4000);
  958. }
  959. };
  960. const installWsCapture = () => {
  961. try {
  962. if (!globalThis.WebSocket) {
  963. wsCaptureEntry.installed = false;
  964. wsCaptureEntry.installReason = 'WebSocket unavailable';
  965. return;
  966. }
  967. if (window[wsCapturePatchKey] !== true) {
  968. const OriginalWebSocket = window.WebSocket;
  969. const originalSend = OriginalWebSocket.prototype.send;
  970. OriginalWebSocket.prototype.send = function patchedSend(payload) {
  971. try {
  972. pushBounded(wsCaptureEntry.outbound, {
  973. createdAt: Date.now(),
  974. url: typeof this?.url === 'string' ? this.url : null,
  975. readyState: Number.isInteger(this?.readyState) ? this.readyState : null,
  976. payload: toWsText(payload),
  977. });
  978. } catch (_error) {
  979. // noop
  980. }
  981. return originalSend.call(this, payload);
  982. };
  983. window.WebSocket = function LogseqWsCapture(...args) {
  984. const ws = new OriginalWebSocket(...args);
  985. try {
  986. ws.addEventListener('message', (event) => {
  987. try {
  988. pushBounded(wsCaptureEntry.inbound, {
  989. createdAt: Date.now(),
  990. url: typeof ws?.url === 'string' ? ws.url : null,
  991. readyState: Number.isInteger(ws?.readyState) ? ws.readyState : null,
  992. payload: toWsText(event?.data),
  993. });
  994. } catch (_error) {
  995. // noop
  996. }
  997. });
  998. } catch (_error) {
  999. // noop
  1000. }
  1001. return ws;
  1002. };
  1003. window.WebSocket.prototype = OriginalWebSocket.prototype;
  1004. Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);
  1005. for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) {
  1006. window.WebSocket[key] = OriginalWebSocket[key];
  1007. }
  1008. window[wsCapturePatchKey] = true;
  1009. }
  1010. wsCaptureEntry.installed = true;
  1011. wsCaptureEntry.installReason = null;
  1012. } catch (error) {
  1013. wsCaptureEntry.installed = false;
  1014. wsCaptureEntry.installReason = describeError(error);
  1015. }
  1016. };
  1017. const latestFatalWarning = () => {
  1018. const warningList = Array.isArray(window[fatalWarningStateKey])
  1019. ? window[fatalWarningStateKey]
  1020. : [];
  1021. return warningList.length > 0 ? warningList[warningList.length - 1] : null;
  1022. };
  1023. const parseCreatedAtMs = (value) => {
  1024. if (value == null) return null;
  1025. if (typeof value === 'number' && Number.isFinite(value)) return value;
  1026. if (value instanceof Date) {
  1027. const ms = value.getTime();
  1028. return Number.isFinite(ms) ? ms : null;
  1029. }
  1030. const ms = new Date(value).getTime();
  1031. return Number.isFinite(ms) ? ms : null;
  1032. };
  1033. const getRtcLogList = () => {
  1034. try {
  1035. if (!globalThis.logseq?.api?.get_state_from_store) return [];
  1036. const logs = logseq.api.get_state_from_store(['rtc/logs']);
  1037. if (Array.isArray(logs) && logs.length > 0) return logs;
  1038. const latest = logseq.api.get_state_from_store(['rtc/log']);
  1039. return latest && typeof latest === 'object' ? [latest] : [];
  1040. } catch (_error) {
  1041. return [];
  1042. }
  1043. };
  1044. const latestChecksumMismatchRtcLog = () => {
  1045. try {
  1046. const logs = getRtcLogList();
  1047. for (let i = logs.length - 1; i >= 0; i -= 1) {
  1048. const entry = logs[i];
  1049. if (!entry || typeof entry !== 'object') continue;
  1050. const createdAtMs = parseCreatedAtMs(entry['created-at'] || entry.createdAt);
  1051. if (Number.isFinite(createdAtMs) && createdAtMs < runStartedAtMs) continue;
  1052. const type = String(entry.type || '').toLowerCase();
  1053. const localChecksum = String(
  1054. entry['local-checksum'] || entry.localChecksum || entry.local_checksum || ''
  1055. );
  1056. const remoteChecksum = String(
  1057. entry['remote-checksum'] || entry.remoteChecksum || entry.remote_checksum || ''
  1058. );
  1059. const hasMismatchType = type.includes('checksum-mismatch');
  1060. const hasDifferentChecksums =
  1061. localChecksum.length > 0 &&
  1062. remoteChecksum.length > 0 &&
  1063. localChecksum !== remoteChecksum;
  1064. if (!hasMismatchType && !hasDifferentChecksums) continue;
  1065. return {
  1066. type: entry.type || null,
  1067. messageType: entry['message-type'] || entry.messageType || null,
  1068. localTx: entry['local-tx'] || entry.localTx || null,
  1069. remoteTx: entry['remote-tx'] || entry.remoteTx || null,
  1070. localChecksum,
  1071. remoteChecksum,
  1072. createdAt: entry['created-at'] || entry.createdAt || null,
  1073. raw: entry,
  1074. };
  1075. }
  1076. return null;
  1077. } catch (_error) {
  1078. return null;
  1079. }
  1080. };
  1081. const latestTxRejectedRtcLog = () => {
  1082. try {
  1083. const logs = getRtcLogList();
  1084. for (let i = logs.length - 1; i >= 0; i -= 1) {
  1085. const entry = logs[i];
  1086. if (!entry || typeof entry !== 'object') continue;
  1087. const createdAtMs = parseCreatedAtMs(entry['created-at'] || entry.createdAt);
  1088. if (Number.isFinite(createdAtMs) && createdAtMs < runStartedAtMs) continue;
  1089. const type = String(entry.type || '').toLowerCase();
  1090. if (!type.includes('tx-rejected')) continue;
  1091. return {
  1092. type: entry.type || null,
  1093. messageType: entry['message-type'] || entry.messageType || null,
  1094. reason: entry.reason || null,
  1095. remoteTx: entry['t'] || entry.t || null,
  1096. createdAt: entry['created-at'] || entry.createdAt || null,
  1097. raw: entry,
  1098. };
  1099. }
  1100. return null;
  1101. } catch (_error) {
  1102. return null;
  1103. }
  1104. };
  1105. const failIfFatalSignalSeen = () => {
  1106. const txRejectedRtcLog = latestTxRejectedRtcLog();
  1107. if (txRejectedRtcLog) {
  1108. throw new Error('tx rejected rtc-log detected: ' + JSON.stringify(txRejectedRtcLog));
  1109. }
  1110. const warning = latestFatalWarning();
  1111. if (!warning) return;
  1112. const details = String(warning.text || '').slice(0, 500);
  1113. if (warning.kind === 'tx_rejected') {
  1114. throw new Error('tx-rejected warning detected: ' + details);
  1115. }
  1116. if (warning.kind === 'missing_entity_id') {
  1117. throw new Error('missing-entity-id warning detected: ' + details);
  1118. }
  1119. if (warning.kind === 'numeric_entity_id_in_non_transact_op') {
  1120. throw new Error('numeric-entity-id-in-non-transact-op warning detected: ' + details);
  1121. }
  1122. // checksum mismatch is recorded for diagnostics but is non-fatal in simulation.
  1123. };
  1124. const clearFatalSignalState = () => {
  1125. try {
  1126. const warningList = Array.isArray(window[fatalWarningStateKey])
  1127. ? window[fatalWarningStateKey]
  1128. : null;
  1129. if (warningList) {
  1130. warningList.length = 0;
  1131. }
  1132. } catch (_error) {
  1133. // noop
  1134. }
  1135. try {
  1136. if (Array.isArray(consoleCaptureEntry)) {
  1137. consoleCaptureEntry.length = 0;
  1138. }
  1139. } catch (_error) {
  1140. // noop
  1141. }
  1142. try {
  1143. if (Array.isArray(wsCaptureEntry?.outbound)) {
  1144. wsCaptureEntry.outbound.length = 0;
  1145. }
  1146. if (Array.isArray(wsCaptureEntry?.inbound)) {
  1147. wsCaptureEntry.inbound.length = 0;
  1148. }
  1149. } catch (_error) {
  1150. // noop
  1151. }
  1152. try {
  1153. if (globalThis.logseq?.api?.set_state_from_store) {
  1154. logseq.api.set_state_from_store(['rtc/log'], null);
  1155. logseq.api.set_state_from_store(['rtc/logs'], []);
  1156. }
  1157. } catch (_error) {
  1158. // noop
  1159. }
  1160. };
  1161. const withTimeout = async (promise, timeoutMs, label) => {
  1162. if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
  1163. return promise;
  1164. }
  1165. let timer = null;
  1166. try {
  1167. return await Promise.race([
  1168. promise,
  1169. new Promise((_, reject) => {
  1170. timer = setTimeout(() => {
  1171. reject(new Error(label + ' timed out after ' + timeoutMs + 'ms'));
  1172. }, timeoutMs);
  1173. }),
  1174. ]);
  1175. } finally {
  1176. if (timer) clearTimeout(timer);
  1177. }
  1178. };
  1179. const flattenBlocks = (nodes, acc = []) => {
  1180. if (!Array.isArray(nodes)) return acc;
  1181. for (const node of nodes) {
  1182. if (!node) continue;
  1183. acc.push(node);
  1184. if (Array.isArray(node.children) && node.children.length > 0) {
  1185. flattenBlocks(node.children, acc);
  1186. }
  1187. }
  1188. return acc;
  1189. };
  1190. const isClientBlock = (block) =>
  1191. typeof block?.content === 'string' && block.content.startsWith(config.markerPrefix);
  1192. const isOperableBlock = (block) =>
  1193. typeof block?.content === 'string' && block.content.startsWith(runPrefix);
  1194. const isClientRootBlock = (block) =>
  1195. typeof block?.content === 'string' && block.content === (config.markerPrefix + ' client-root');
  1196. let operationPageName = null;
  1197. const listPageBlocks = async () => {
  1198. if (
  1199. typeof operationPageName === 'string' &&
  1200. operationPageName.length > 0 &&
  1201. typeof logseq.api.get_page_blocks_tree === 'function'
  1202. ) {
  1203. const tree = await logseq.api.get_page_blocks_tree(operationPageName);
  1204. return flattenBlocks(tree, []);
  1205. }
  1206. const tree = await logseq.api.get_current_page_blocks_tree();
  1207. return flattenBlocks(tree, []);
  1208. };
  1209. const listOperableBlocks = async () => {
  1210. const flattened = await listPageBlocks();
  1211. return flattened.filter(isOperableBlock);
  1212. };
  1213. const listManagedBlocks = async () => {
  1214. const operableBlocks = await listOperableBlocks();
  1215. return operableBlocks.filter(isClientBlock);
  1216. };
  1217. const ensureClientRootBlock = async (anchorBlock) => {
  1218. const existing = (await listOperableBlocks()).find(isClientRootBlock);
  1219. if (existing?.uuid) return existing;
  1220. const inserted = await logseq.api.insert_block(anchorBlock.uuid, config.markerPrefix + ' client-root', {
  1221. sibling: true,
  1222. before: false,
  1223. focus: false,
  1224. });
  1225. if (!inserted?.uuid) {
  1226. throw new Error('Failed to create client root block');
  1227. }
  1228. return inserted;
  1229. };
  1230. const pickIndentCandidate = async (blocks) => {
  1231. for (const candidate of shuffle(blocks)) {
  1232. const prev = await logseq.api.get_previous_sibling_block(candidate.uuid);
  1233. if (prev?.uuid) return candidate;
  1234. }
  1235. return null;
  1236. };
  1237. const pickOutdentCandidate = async (blocks) => {
  1238. for (const candidate of shuffle(blocks)) {
  1239. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  1240. const parentId = full?.parent?.id;
  1241. const pageId = full?.page?.id;
  1242. if (parentId && pageId && parentId !== pageId) {
  1243. return candidate;
  1244. }
  1245. }
  1246. return null;
  1247. };
  1248. const getPreviousSiblingUuid = async (uuid) => {
  1249. const prev = await logseq.api.get_previous_sibling_block(uuid);
  1250. return prev?.uuid || null;
  1251. };
  1252. const getNextSiblingUuid = async (uuid) => {
  1253. const next = await logseq.api.get_next_sibling_block(uuid);
  1254. return next?.uuid || null;
  1255. };
  1256. const pickMoveUpDownCandidate = async (blocks, up) => {
  1257. for (const candidate of shuffle(blocks)) {
  1258. if (!candidate?.uuid || isClientRootBlock(candidate)) continue;
  1259. const siblingUuid = up
  1260. ? await getPreviousSiblingUuid(candidate.uuid)
  1261. : await getNextSiblingUuid(candidate.uuid);
  1262. if (siblingUuid) {
  1263. return { candidate, siblingUuid };
  1264. }
  1265. }
  1266. return null;
  1267. };
  1268. const ensureMoveUpDownCandidate = async (blocks, anchorBlock, opIndex, up) => {
  1269. const existing = await pickMoveUpDownCandidate(blocks, up);
  1270. if (existing?.candidate?.uuid) return existing;
  1271. const baseTarget = blocks.length > 0 ? randomItem(blocks) : anchorBlock;
  1272. const first = await logseq.api.insert_block(baseTarget.uuid, config.markerPrefix + ' move-up-down-a-' + opIndex, {
  1273. sibling: true,
  1274. before: false,
  1275. focus: false,
  1276. });
  1277. if (!first?.uuid) {
  1278. throw new Error('Failed to create move-up-down first block');
  1279. }
  1280. const second = await logseq.api.insert_block(first.uuid, config.markerPrefix + ' move-up-down-b-' + opIndex, {
  1281. sibling: true,
  1282. before: false,
  1283. focus: false,
  1284. });
  1285. if (!second?.uuid) {
  1286. throw new Error('Failed to create move-up-down second block');
  1287. }
  1288. if (up) {
  1289. return { candidate: second, siblingUuid: first.uuid };
  1290. }
  1291. return { candidate: first, siblingUuid: second.uuid };
  1292. };
  1293. const ensureIndentCandidate = async (blocks, anchorBlock, opIndex) => {
  1294. const existing = await pickIndentCandidate(blocks);
  1295. if (existing?.uuid) return existing;
  1296. const baseTarget = blocks.length > 0 ? randomItem(blocks) : anchorBlock;
  1297. const base = await logseq.api.insert_block(baseTarget.uuid, config.markerPrefix + ' indent-base-' + opIndex, {
  1298. sibling: true,
  1299. before: false,
  1300. focus: false,
  1301. });
  1302. if (!base?.uuid) {
  1303. throw new Error('Failed to create indent base block');
  1304. }
  1305. const candidate = await logseq.api.insert_block(base.uuid, config.markerPrefix + ' indent-candidate-' + opIndex, {
  1306. sibling: true,
  1307. before: false,
  1308. focus: false,
  1309. });
  1310. if (!candidate?.uuid) {
  1311. throw new Error('Failed to create indent candidate block');
  1312. }
  1313. return candidate;
  1314. };
  1315. const runIndent = async (candidate) => {
  1316. const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
  1317. if (!prevUuid) {
  1318. throw new Error('No previous sibling for indent candidate');
  1319. }
  1320. await logseq.api.move_block(candidate.uuid, prevUuid, {
  1321. before: false,
  1322. children: true,
  1323. });
  1324. };
  1325. const ensureOutdentCandidate = async (blocks, anchorBlock, opIndex) => {
  1326. const existing = await pickOutdentCandidate(blocks);
  1327. if (existing?.uuid) return existing;
  1328. const candidate = await ensureIndentCandidate(blocks, anchorBlock, opIndex);
  1329. await runIndent(candidate);
  1330. return candidate;
  1331. };
  1332. const runOutdent = async (candidate) => {
  1333. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  1334. const parentId = full?.parent?.id;
  1335. const pageId = full?.page?.id;
  1336. if (!parentId || !pageId || parentId === pageId) {
  1337. throw new Error('Outdent candidate is not nested');
  1338. }
  1339. const parent = await logseq.api.get_block(parentId, { includeChildren: false });
  1340. if (!parent?.uuid) {
  1341. throw new Error('Cannot resolve parent block for outdent');
  1342. }
  1343. await logseq.api.move_block(candidate.uuid, parent.uuid, {
  1344. before: false,
  1345. children: false,
  1346. });
  1347. };
  1348. const requireFunctionAtPath = (pathText, label) => {
  1349. const parts = String(pathText || '')
  1350. .split('.')
  1351. .filter((part) => part.length > 0);
  1352. let value = globalThis;
  1353. for (const part of parts) {
  1354. value = value ? value[part] : undefined;
  1355. }
  1356. if (typeof value !== 'function') {
  1357. throw new Error(label + ' is unavailable at path: ' + pathText);
  1358. }
  1359. return value;
  1360. };
  1361. const ensureCljsInterop = () => {
  1362. if (!globalThis.cljs?.core) {
  1363. throw new Error('cljs.core is unavailable; cannot run full outliner-op coverage');
  1364. }
  1365. if (!globalThis.frontend?.db?.transact?.apply_outliner_ops) {
  1366. throw new Error('frontend.db.transact.apply_outliner_ops is unavailable');
  1367. }
  1368. if (!globalThis.frontend?.db?.conn?.get_db) {
  1369. throw new Error('frontend.db.conn.get_db is unavailable');
  1370. }
  1371. return globalThis.cljs.core;
  1372. };
  1373. let cljsInterop = null;
  1374. const getCljsInterop = () => {
  1375. if (cljsInterop) return cljsInterop;
  1376. const cljsCore = ensureCljsInterop();
  1377. const kw = (name) => cljsCore.keyword(String(name));
  1378. const keywordizeOpts = cljsCore.PersistentArrayMap.fromArray(
  1379. [kw('keywordize-keys'), true],
  1380. true
  1381. );
  1382. cljsInterop = { cljsCore, kw, keywordizeOpts };
  1383. return cljsInterop;
  1384. };
  1385. const waitForOutlinerInteropReady = async () => {
  1386. const deadline = Date.now() + Math.max(
  1387. Number(config.readyTimeoutMs || 0),
  1388. 45000
  1389. );
  1390. let lastError = null;
  1391. while (Date.now() < deadline) {
  1392. try {
  1393. getCljsInterop();
  1394. return;
  1395. } catch (error) {
  1396. lastError = error;
  1397. }
  1398. await sleep(Math.max(50, Number(config.readyPollDelayMs || 0) || 250));
  1399. }
  1400. throw new Error(
  1401. 'Outliner interop readiness timed out: ' +
  1402. describeError(lastError || new Error('unknown reason'))
  1403. );
  1404. };
  1405. const applyRawOutlinerOp = async (opName, args, txMetaOutlinerOp = opName) => {
  1406. const { cljsCore, kw, keywordizeOpts } = getCljsInterop();
  1407. const toClj = (value) => cljsCore.js__GT_clj(value, keywordizeOpts);
  1408. const conn = frontend.db.conn.get_db(false);
  1409. const cljArgs = toClj(args);
  1410. const opVec = cljsCore.PersistentVector.fromArray([kw(opName), cljArgs], true);
  1411. const opsVec = cljsCore.PersistentVector.fromArray([opVec], true);
  1412. const txMeta = cljsCore.PersistentArrayMap.fromArray(
  1413. [kw('outliner-op'), kw(txMetaOutlinerOp)],
  1414. true
  1415. );
  1416. return frontend.db.transact.apply_outliner_ops(conn, opsVec, txMeta);
  1417. };
  1418. const queryEidByUuid = async (uuidText) => {
  1419. const eid = await logseq.api.datascript_query(
  1420. '[:find ?e . :in $ ?uuid :where [?e :block/uuid ?uuid]]',
  1421. JSON.stringify(String(uuidText))
  1422. );
  1423. return Number.isInteger(eid) ? eid : null;
  1424. };
  1425. const queryEidByIdent = async (identKeywordText) => {
  1426. const eid = await logseq.api.datascript_query(
  1427. '[:find ?e . :in $ ?ident :where [?e :db/ident ?ident]]',
  1428. String(identKeywordText)
  1429. );
  1430. return Number.isInteger(eid) ? eid : null;
  1431. };
  1432. const getEntityUuid = (entity) =>
  1433. entity?.uuid || entity?.['block/uuid'] || entity?.block?.uuid || null;
  1434. const runAllOutlinerOpsCoveragePass = async (anchorBlock) => {
  1435. const opResults = [];
  1436. const failures = [];
  1437. const coveragePrefix = config.markerPrefix + ' outliner-op-coverage ';
  1438. const coverageStepTimeoutMs = Math.max(
  1439. 45000,
  1440. Number(config.opTimeoutMs || 0) * 20
  1441. );
  1442. const runStep = async (opName, action) => {
  1443. const startedAt = Date.now();
  1444. try {
  1445. const detail = await withTimeout(
  1446. Promise.resolve().then(action),
  1447. coverageStepTimeoutMs,
  1448. 'outliner-op coverage step ' + opName
  1449. );
  1450. opResults.push({
  1451. op: opName,
  1452. ok: true,
  1453. detail: detail || null,
  1454. durationMs: Date.now() - startedAt,
  1455. });
  1456. } catch (error) {
  1457. const message = describeError(error);
  1458. opResults.push({
  1459. op: opName,
  1460. ok: false,
  1461. error: message,
  1462. durationMs: Date.now() - startedAt,
  1463. });
  1464. failures.push({ op: opName, error: message });
  1465. }
  1466. };
  1467. const insertCoverageBlock = async (suffix, target = anchorBlock, opts = {}) =>
  1468. logseq.api.insert_block(
  1469. target.uuid,
  1470. coveragePrefix + suffix,
  1471. {
  1472. sibling: opts.sibling ?? true,
  1473. before: opts.before ?? false,
  1474. focus: false,
  1475. }
  1476. );
  1477. const blockA = await insertCoverageBlock('block-a');
  1478. const blockB = await insertCoverageBlock('block-b');
  1479. const blockC = await insertCoverageBlock('block-c');
  1480. const blockAUuid = getEntityUuid(blockA);
  1481. const blockBUuid = getEntityUuid(blockB);
  1482. const blockCUuid = getEntityUuid(blockC);
  1483. if (!blockAUuid || !blockBUuid || !blockCUuid) {
  1484. throw new Error('Failed to create coverage blocks with UUIDs');
  1485. }
  1486. const blockAEid = await queryEidByUuid(blockAUuid);
  1487. const blockBEid = await queryEidByUuid(blockBUuid);
  1488. const blockCEid = await queryEidByUuid(blockCUuid);
  1489. if (!Number.isInteger(blockAEid) || !Number.isInteger(blockBEid) || !Number.isInteger(blockCEid)) {
  1490. throw new Error('Failed to resolve coverage block entity ids');
  1491. }
  1492. const propertyName = (config.markerPrefix + 'cov-prop-' + Date.now())
  1493. .toLowerCase()
  1494. .replace(/[^a-z0-9-]+/g, '-');
  1495. const tagName = (config.markerPrefix + 'cov-tag-' + Date.now())
  1496. .replace(/[^a-zA-Z0-9_-]+/g, '-');
  1497. const pageBaseName = (config.markerPrefix + 'cov-page-' + Date.now())
  1498. .toLowerCase()
  1499. .replace(/[^a-z0-9-]+/g, '-');
  1500. const propertyEntity = await logseq.api.upsert_property(
  1501. propertyName,
  1502. { type: 'default', cardinality: 'many' },
  1503. {}
  1504. );
  1505. const propertyUuid = getEntityUuid(propertyEntity) || getEntityUuid(await logseq.api.get_property(propertyName));
  1506. if (!propertyUuid) {
  1507. throw new Error('Failed to resolve coverage property uuid');
  1508. }
  1509. const propertyEid = await queryEidByUuid(propertyUuid);
  1510. if (!Number.isInteger(propertyEid)) {
  1511. throw new Error('Failed to resolve coverage property entity id');
  1512. }
  1513. const tagEntity = await logseq.api.create_tag(tagName, {});
  1514. const tagUuid = getEntityUuid(tagEntity);
  1515. if (!tagUuid) {
  1516. throw new Error('Failed to resolve coverage tag uuid');
  1517. }
  1518. const tagEid = await queryEidByUuid(tagUuid);
  1519. if (!Number.isInteger(tagEid)) {
  1520. throw new Error('Failed to resolve coverage tag entity id');
  1521. }
  1522. const blockTagsEid = await queryEidByIdent(':block/tags');
  1523. if (!Number.isInteger(blockTagsEid)) {
  1524. throw new Error('Failed to resolve :block/tags entity id');
  1525. }
  1526. const templateRoot = await insertCoverageBlock('template-root');
  1527. const templateRootUuid = getEntityUuid(templateRoot);
  1528. if (!templateRootUuid) {
  1529. throw new Error('Failed to create template root block');
  1530. }
  1531. const templateChild = await logseq.api.insert_block(
  1532. templateRootUuid,
  1533. coveragePrefix + 'template-child',
  1534. { sibling: false, before: false, focus: false }
  1535. );
  1536. if (!getEntityUuid(templateChild)) {
  1537. throw new Error('Failed to create template child block');
  1538. }
  1539. const templateRootEid = await queryEidByUuid(templateRootUuid);
  1540. if (!Number.isInteger(templateRootEid)) {
  1541. throw new Error('Failed to resolve template root entity id');
  1542. }
  1543. const restoreRecycled = requireFunctionAtPath(
  1544. 'frontend.handler.page.restore_recycled_BANG_',
  1545. 'restore recycled handler'
  1546. );
  1547. const deleteRecycledPermanently = requireFunctionAtPath(
  1548. 'frontend.handler.page.delete_recycled_permanently_BANG_',
  1549. 'recycle delete permanently handler'
  1550. );
  1551. await runStep('save-block', async () => {
  1552. await applyRawOutlinerOp('save-block', [
  1553. {
  1554. 'block/uuid': blockAUuid,
  1555. 'block/title': coveragePrefix + 'save-block',
  1556. },
  1557. {},
  1558. ]);
  1559. });
  1560. await runStep('insert-blocks', async () => {
  1561. await logseq.api.insert_block(blockAUuid, coveragePrefix + 'insert-blocks', {
  1562. sibling: true,
  1563. before: false,
  1564. focus: false,
  1565. });
  1566. });
  1567. await runStep('apply-template', async () => {
  1568. await applyRawOutlinerOp('apply-template', [
  1569. templateRootEid,
  1570. blockBEid,
  1571. { sibling: true },
  1572. ]);
  1573. });
  1574. await runStep('move-blocks', async () => {
  1575. await applyRawOutlinerOp('move-blocks', [
  1576. [blockAEid],
  1577. blockBEid,
  1578. { sibling: true },
  1579. ]);
  1580. });
  1581. await runStep('move-blocks-up-down', async () => {
  1582. await applyRawOutlinerOp('move-blocks-up-down', [[blockBEid], true]);
  1583. });
  1584. await runStep('indent-outdent-blocks', async () => {
  1585. await applyRawOutlinerOp('indent-outdent-blocks', [[blockCEid], true, {}]);
  1586. });
  1587. await runStep('delete-blocks', async () => {
  1588. await logseq.api.remove_block(blockCUuid);
  1589. });
  1590. await runStep('upsert-property', async () => {
  1591. await logseq.api.upsert_property(
  1592. propertyName,
  1593. { type: 'default', cardinality: 'many' },
  1594. { properties: { description: coveragePrefix + 'upsert-property' } }
  1595. );
  1596. });
  1597. await runStep('set-block-property', async () => {
  1598. await logseq.api.upsert_block_property(blockAUuid, propertyName, coveragePrefix + 'set-block-property', {});
  1599. });
  1600. await runStep('batch-set-property', async () => {
  1601. await applyRawOutlinerOp('batch-set-property', [
  1602. [blockAEid, blockBEid],
  1603. propertyEid,
  1604. coveragePrefix + 'batch-set-property',
  1605. {},
  1606. ]);
  1607. });
  1608. await runStep('batch-remove-property', async () => {
  1609. await applyRawOutlinerOp('batch-remove-property', [
  1610. [blockAEid, blockBEid],
  1611. propertyEid,
  1612. ]);
  1613. });
  1614. await runStep('remove-block-property', async () => {
  1615. await logseq.api.remove_block_property(blockAUuid, propertyName);
  1616. });
  1617. await runStep('delete-property-value', async () => {
  1618. await logseq.api.add_block_tag(blockAUuid, tagUuid);
  1619. await logseq.api.remove_block_tag(blockAUuid, tagUuid);
  1620. });
  1621. await runStep('batch-delete-property-value', async () => {
  1622. await logseq.api.add_block_tag(blockAUuid, tagUuid);
  1623. await logseq.api.add_block_tag(blockBUuid, tagUuid);
  1624. await applyRawOutlinerOp('batch-delete-property-value', [
  1625. [blockAEid, blockBEid],
  1626. blockTagsEid,
  1627. tagEid,
  1628. ]);
  1629. });
  1630. await runStep('create-property-text-block', async () => {
  1631. await applyRawOutlinerOp('create-property-text-block', [
  1632. blockAEid,
  1633. propertyEid,
  1634. coveragePrefix + 'property-text-value',
  1635. {},
  1636. ]);
  1637. });
  1638. await runStep('collapse-expand-block-property', async () => {
  1639. await applyRawOutlinerOp('collapse-expand-block-property', [
  1640. blockAEid,
  1641. propertyEid,
  1642. true,
  1643. ]);
  1644. await applyRawOutlinerOp('collapse-expand-block-property', [
  1645. blockAEid,
  1646. propertyEid,
  1647. false,
  1648. ]);
  1649. });
  1650. const closedValueText = coveragePrefix + 'closed-choice';
  1651. await runStep('upsert-closed-value', async () => {
  1652. await applyRawOutlinerOp('upsert-closed-value', [
  1653. propertyEid,
  1654. { value: closedValueText },
  1655. ]);
  1656. });
  1657. await runStep('delete-closed-value', async () => {
  1658. const valueBlockEid = await logseq.api.datascript_query(
  1659. '[:find ?e . :in $ ?property-id ?value :where [?e :block/closed-value-property ?property-id] (or [?e :block/title ?value] [?e :logseq.property/value ?value])]',
  1660. String(propertyEid),
  1661. JSON.stringify(closedValueText)
  1662. );
  1663. if (!Number.isInteger(valueBlockEid)) {
  1664. throw new Error('Failed to find closed value block eid for delete-closed-value');
  1665. }
  1666. await applyRawOutlinerOp('delete-closed-value', [propertyEid, valueBlockEid]);
  1667. });
  1668. await runStep('add-existing-values-to-closed-values', async () => {
  1669. await applyRawOutlinerOp('add-existing-values-to-closed-values', [
  1670. propertyEid,
  1671. [blockBUuid],
  1672. ]);
  1673. });
  1674. await runStep('class-add-property', async () => {
  1675. await logseq.api.add_tag_property(tagUuid, propertyName);
  1676. });
  1677. await runStep('class-remove-property', async () => {
  1678. await logseq.api.remove_tag_property(tagUuid, propertyName);
  1679. });
  1680. await runStep('toggle-reaction', async () => {
  1681. await applyRawOutlinerOp('toggle-reaction', [blockAUuid, 'thumbsup', null]);
  1682. });
  1683. await runStep('transact', async () => {
  1684. await applyRawOutlinerOp('transact', [
  1685. [
  1686. {
  1687. 'db/id': blockAEid,
  1688. 'block/title': coveragePrefix + 'transact-title',
  1689. },
  1690. ],
  1691. null,
  1692. ]);
  1693. });
  1694. let coveragePageUuid = null;
  1695. await runStep('create-page', async () => {
  1696. const page = await logseq.api.create_page(pageBaseName, null, { redirect: false });
  1697. coveragePageUuid = getEntityUuid(page) || getEntityUuid(await logseq.api.get_page(pageBaseName));
  1698. if (!coveragePageUuid) {
  1699. throw new Error('Failed to create coverage page');
  1700. }
  1701. });
  1702. const renamedPageName = pageBaseName + '-renamed';
  1703. await runStep('rename-page', async () => {
  1704. if (!coveragePageUuid) {
  1705. throw new Error('Coverage page UUID missing before rename-page');
  1706. }
  1707. await logseq.api.rename_page(coveragePageUuid, renamedPageName);
  1708. });
  1709. await runStep('delete-page', async () => {
  1710. await logseq.api.delete_page(renamedPageName);
  1711. });
  1712. await runStep('batch-import-edn', async () => {
  1713. // Use a minimal payload to exercise the outliner-op path without running
  1714. // a full export/import cycle that can reopen sqlite resources.
  1715. const result = await applyRawOutlinerOp('batch-import-edn', [{}, {}]);
  1716. return {
  1717. returnedError: result?.error || null,
  1718. };
  1719. });
  1720. await runStep('recycle-delete-permanently', async () => {
  1721. const recyclePageName = pageBaseName + '-perm-delete';
  1722. const page = await logseq.api.create_page(recyclePageName, null, { redirect: false });
  1723. const pageUuid = getEntityUuid(page) || getEntityUuid(await logseq.api.get_page(recyclePageName));
  1724. if (!pageUuid) {
  1725. throw new Error('Failed to create recycle-delete-permanently page');
  1726. }
  1727. await logseq.api.delete_page(recyclePageName);
  1728. await deleteRecycledPermanently(pageUuid);
  1729. });
  1730. await runStep('restore-recycled', async () => {
  1731. const recyclePageName = pageBaseName + '-restore';
  1732. const page = await logseq.api.create_page(recyclePageName, null, { redirect: false });
  1733. const pageUuid = getEntityUuid(page) || getEntityUuid(await logseq.api.get_page(recyclePageName));
  1734. if (!pageUuid) {
  1735. throw new Error('Failed to create restore-recycled page');
  1736. }
  1737. await logseq.api.delete_page(recyclePageName);
  1738. await restoreRecycled(pageUuid);
  1739. });
  1740. const coveredOps = Array.from(new Set(opResults.map((entry) => entry.op)));
  1741. const missingOps = expectedOutlinerOps.filter((op) => !coveredOps.includes(op));
  1742. const unexpectedOps = coveredOps.filter((op) => !expectedOutlinerOps.includes(op));
  1743. for (const op of missingOps) {
  1744. failures.push({ op, error: 'op coverage missing from runAllOutlinerOpsCoveragePass' });
  1745. }
  1746. for (const op of unexpectedOps) {
  1747. failures.push({ op, error: 'unexpected op recorded during coverage pass' });
  1748. }
  1749. const summary = {
  1750. ok: failures.length === 0,
  1751. total: opResults.length,
  1752. passed: opResults.length - failures.length,
  1753. failed: failures.length,
  1754. stepTimeoutMs: coverageStepTimeoutMs,
  1755. expectedOps: expectedOutlinerOps,
  1756. coveredOps,
  1757. missingOps,
  1758. unexpectedOps,
  1759. failedOps: failures,
  1760. sample: opResults.slice(0, 50),
  1761. };
  1762. if (failures.length > 0) {
  1763. throw new Error(
  1764. 'Full outliner-op coverage failed: ' + JSON.stringify(summary.failedOps.slice(0, 5))
  1765. );
  1766. }
  1767. return summary;
  1768. };
  1769. const pickRandomGroup = (blocks, minSize = 1, maxSize = 3) => {
  1770. const pool = shuffle(blocks);
  1771. const lower = Math.max(1, Math.min(minSize, pool.length));
  1772. const upper = Math.max(lower, Math.min(maxSize, pool.length));
  1773. const size = lower + Math.floor(nextRandom() * (upper - lower + 1));
  1774. return pool.slice(0, size);
  1775. };
  1776. const toBatchTree = (block) => ({
  1777. content: typeof block?.content === 'string' ? block.content : '',
  1778. children: Array.isArray(block?.children) ? block.children.map(toBatchTree) : [],
  1779. });
  1780. const getAnchor = async () => {
  1781. const deadline = Date.now() + config.readyTimeoutMs;
  1782. let lastError = null;
  1783. while (Date.now() < deadline) {
  1784. try {
  1785. if (typeof logseq.api.get_today_page === 'function') {
  1786. const todayPage = await logseq.api.get_today_page();
  1787. const todayPageName = asPageName(todayPage);
  1788. if (todayPageName) {
  1789. operationPageName = todayPageName;
  1790. const seeded = await logseq.api.append_block_in_page(
  1791. todayPageName,
  1792. config.markerPrefix + ' anchor',
  1793. {}
  1794. );
  1795. if (seeded?.uuid) return seeded;
  1796. }
  1797. }
  1798. if (typeof logseq.api.get_current_page === 'function') {
  1799. const currentPage = await logseq.api.get_current_page();
  1800. const currentPageName = asPageName(currentPage);
  1801. if (currentPageName) {
  1802. operationPageName = currentPageName;
  1803. const seeded = await logseq.api.append_block_in_page(
  1804. currentPageName,
  1805. config.markerPrefix + ' anchor',
  1806. {}
  1807. );
  1808. if (seeded?.uuid) return seeded;
  1809. }
  1810. }
  1811. const currentBlock = await logseq.api.get_current_block();
  1812. if (currentBlock && currentBlock.uuid) {
  1813. return currentBlock;
  1814. }
  1815. {
  1816. operationPageName = config.fallbackPageName;
  1817. const seeded = await logseq.api.append_block_in_page(
  1818. config.fallbackPageName,
  1819. config.markerPrefix + ' anchor',
  1820. {}
  1821. );
  1822. if (seeded?.uuid) return seeded;
  1823. }
  1824. } catch (error) {
  1825. lastError = error;
  1826. }
  1827. await sleep(config.readyPollDelayMs);
  1828. }
  1829. if (lastError) {
  1830. throw new Error('Unable to resolve anchor block: ' + describeError(lastError));
  1831. }
  1832. throw new Error('Unable to resolve anchor block: open a graph and page, then retry');
  1833. };
  1834. const parseRtcTxText = (text) => {
  1835. if (typeof text !== 'string' || text.length === 0) return null;
  1836. const localMatch = text.match(/:local-tx\\s+(-?\\d+)/);
  1837. const remoteMatch = text.match(/:remote-tx\\s+(-?\\d+)/);
  1838. if (!localMatch || !remoteMatch) return null;
  1839. return {
  1840. localTx: Number.parseInt(localMatch[1], 10),
  1841. remoteTx: Number.parseInt(remoteMatch[1], 10),
  1842. };
  1843. };
  1844. const readRtcTx = () => {
  1845. const node = document.querySelector('[data-testid="rtc-tx"]');
  1846. if (!node) return null;
  1847. return parseRtcTxText((node.textContent || '').trim());
  1848. };
  1849. const waitForRtcSettle = async () => {
  1850. const deadline = Date.now() + config.syncSettleTimeoutMs;
  1851. let stableHits = 0;
  1852. let last = null;
  1853. while (Date.now() < deadline) {
  1854. const current = readRtcTx();
  1855. if (current && Number.isFinite(current.localTx) && Number.isFinite(current.remoteTx)) {
  1856. last = current;
  1857. if (current.localTx === current.remoteTx) {
  1858. stableHits += 1;
  1859. if (stableHits >= 3) return { ok: true, ...current };
  1860. } else {
  1861. stableHits = 0;
  1862. }
  1863. }
  1864. await sleep(250);
  1865. }
  1866. return { ok: false, ...(last || {}), reason: 'rtc-tx did not settle before timeout' };
  1867. };
  1868. const extractNotificationTexts = () =>
  1869. Array.from(
  1870. document.querySelectorAll('.ui__notifications-content .text-sm.leading-5.font-medium.whitespace-pre-line')
  1871. )
  1872. .map((el) => (el.textContent || '').trim())
  1873. .filter(Boolean);
  1874. const parseChecksumNotification = (text) => {
  1875. if (typeof text !== 'string' || !text.includes('Checksum recomputed.')) return null;
  1876. const match = text.match(
  1877. /Recomputed:\\s*([0-9a-fA-F]{16})\\s*,\\s*local:\\s*([^,]+)\\s*,\\s*remote:\\s*([^,\\.]+)/
  1878. );
  1879. if (!match) {
  1880. return {
  1881. raw: text,
  1882. parsed: false,
  1883. reason: 'notification did not match expected checksum format',
  1884. };
  1885. }
  1886. const normalize = (value) => {
  1887. const trimmed = String(value || '').trim();
  1888. if (trimmed === '<nil>') return null;
  1889. return trimmed;
  1890. };
  1891. const recomputed = normalize(match[1]);
  1892. const local = normalize(match[2]);
  1893. const remote = normalize(match[3]);
  1894. const localMatches = recomputed === local;
  1895. const remoteMatches = recomputed === remote;
  1896. const localRemoteMatch = local === remote;
  1897. return {
  1898. raw: text,
  1899. parsed: true,
  1900. recomputed,
  1901. local,
  1902. remote,
  1903. localMatches,
  1904. remoteMatches,
  1905. localRemoteMatch,
  1906. matched: localMatches && remoteMatches && localRemoteMatch,
  1907. };
  1908. };
  1909. const runChecksumDiagnostics = async () => {
  1910. const settle = await waitForRtcSettle();
  1911. if (!settle.ok) {
  1912. return {
  1913. ok: false,
  1914. settle,
  1915. reason: settle.reason || 'sync did not settle',
  1916. };
  1917. }
  1918. const before = new Set(extractNotificationTexts());
  1919. const commandCandidates = ['dev/recompute-checksum', ':dev/recompute-checksum'];
  1920. let invoked = null;
  1921. let invokeError = null;
  1922. for (const command of commandCandidates) {
  1923. try {
  1924. await logseq.api.invoke_external_command(command);
  1925. invoked = command;
  1926. invokeError = null;
  1927. break;
  1928. } catch (error) {
  1929. invokeError = error;
  1930. }
  1931. }
  1932. if (!invoked) {
  1933. return {
  1934. ok: false,
  1935. settle,
  1936. reason: 'failed to invoke checksum command',
  1937. error: describeError(invokeError),
  1938. };
  1939. }
  1940. const deadline = Date.now() + Math.max(10000, config.readyTimeoutMs);
  1941. let seen = null;
  1942. while (Date.now() < deadline) {
  1943. const current = extractNotificationTexts();
  1944. for (const text of current) {
  1945. if (before.has(text)) continue;
  1946. const parsed = parseChecksumNotification(text);
  1947. if (parsed) {
  1948. return {
  1949. ok: Boolean(parsed.matched),
  1950. settle,
  1951. invoked,
  1952. ...parsed,
  1953. };
  1954. }
  1955. seen = text;
  1956. }
  1957. await sleep(250);
  1958. }
  1959. return {
  1960. ok: false,
  1961. settle,
  1962. invoked,
  1963. reason: 'checksum notification not found before timeout',
  1964. seen,
  1965. };
  1966. };
  1967. const replayCaptureEnabled = config.captureReplay !== false;
  1968. const replayCaptureStoreKey = '__logseqOpReplayCaptureStore';
  1969. const replayAttrByNormalizedName = {
  1970. uuid: ':block/uuid',
  1971. title: ':block/title',
  1972. name: ':block/name',
  1973. parent: ':block/parent',
  1974. page: ':block/page',
  1975. order: ':block/order',
  1976. };
  1977. const replayCaptureState = {
  1978. installed: false,
  1979. installReason: null,
  1980. enabled: false,
  1981. currentOpIndex: null,
  1982. txLog: [],
  1983. };
  1984. const replayCaptureStoreRoot =
  1985. window[replayCaptureStoreKey] && typeof window[replayCaptureStoreKey] === 'object'
  1986. ? window[replayCaptureStoreKey]
  1987. : {};
  1988. window[replayCaptureStoreKey] = replayCaptureStoreRoot;
  1989. const replayCaptureStoreEntry =
  1990. replayCaptureStoreRoot[config.markerPrefix] &&
  1991. typeof replayCaptureStoreRoot[config.markerPrefix] === 'object'
  1992. ? replayCaptureStoreRoot[config.markerPrefix]
  1993. : {};
  1994. replayCaptureStoreEntry.markerPrefix = config.markerPrefix;
  1995. replayCaptureStoreEntry.updatedAt = Date.now();
  1996. replayCaptureStoreEntry.initialDb = null;
  1997. replayCaptureStoreEntry.opLog = [];
  1998. replayCaptureStoreEntry.txCapture = {
  1999. enabled: replayCaptureEnabled,
  2000. installed: false,
  2001. installReason: null,
  2002. totalTx: 0,
  2003. txLog: [],
  2004. };
  2005. replayCaptureStoreRoot[config.markerPrefix] = replayCaptureStoreEntry;
  2006. const readAny = (value, keys) => {
  2007. if (!value || typeof value !== 'object') return undefined;
  2008. for (const key of keys) {
  2009. if (Object.prototype.hasOwnProperty.call(value, key)) {
  2010. return value[key];
  2011. }
  2012. }
  2013. return undefined;
  2014. };
  2015. const normalizeReplayAttr = (value) => {
  2016. if (typeof value !== 'string') return null;
  2017. const text = value.trim();
  2018. if (!text) return null;
  2019. if (text.startsWith(':')) {
  2020. return text;
  2021. }
  2022. if (text.includes('/')) {
  2023. return ':' + text;
  2024. }
  2025. return replayAttrByNormalizedName[text] || null;
  2026. };
  2027. const normalizeReplayDatom = (datom) => {
  2028. if (!datom || typeof datom !== 'object') return null;
  2029. const eRaw = readAny(datom, ['e', ':e']);
  2030. const aRaw = readAny(datom, ['a', ':a']);
  2031. const vRaw = readAny(datom, ['v', ':v']);
  2032. const addedRaw = readAny(datom, ['added', ':added']);
  2033. const e = Number(eRaw);
  2034. if (!Number.isInteger(e)) return null;
  2035. const attr = normalizeReplayAttr(typeof aRaw === 'string' ? aRaw : String(aRaw || ''));
  2036. if (!attr) return null;
  2037. let v = vRaw;
  2038. if (attr === ':block/uuid' && typeof vRaw === 'string') {
  2039. v = vRaw;
  2040. } else if ((attr === ':block/parent' || attr === ':block/page') && Number.isFinite(Number(vRaw))) {
  2041. v = Number(vRaw);
  2042. }
  2043. return {
  2044. e,
  2045. a: attr,
  2046. v,
  2047. added: addedRaw !== false,
  2048. };
  2049. };
  2050. const installReplayTxCapture = () => {
  2051. if (!replayCaptureEnabled) {
  2052. replayCaptureState.installReason = 'disabled by config';
  2053. replayCaptureStoreEntry.txCapture.installReason = replayCaptureState.installReason;
  2054. return;
  2055. }
  2056. const core = window.LSPluginCore;
  2057. if (!core || typeof core.hookDb !== 'function') {
  2058. replayCaptureState.installReason = 'LSPluginCore.hookDb unavailable';
  2059. replayCaptureStoreEntry.txCapture.installReason = replayCaptureState.installReason;
  2060. return;
  2061. }
  2062. const sinkKey = '__logseqOpReplayCaptureSinks';
  2063. const patchInstalledKey = '__logseqOpReplayCapturePatchInstalled';
  2064. const sinks = Array.isArray(window[sinkKey]) ? window[sinkKey] : [];
  2065. window[sinkKey] = sinks;
  2066. const sink = (type, payload) => {
  2067. try {
  2068. if (replayCaptureState.enabled && String(type || '') === 'changed' && payload && typeof payload === 'object') {
  2069. const rawDatoms = readAny(payload, ['txData', ':tx-data', 'tx-data', 'tx_data']);
  2070. const datoms = Array.isArray(rawDatoms)
  2071. ? rawDatoms.map(normalizeReplayDatom).filter(Boolean)
  2072. : [];
  2073. if (datoms.length > 0) {
  2074. const entry = {
  2075. capturedAt: Date.now(),
  2076. opIndex: Number.isInteger(replayCaptureState.currentOpIndex)
  2077. ? replayCaptureState.currentOpIndex
  2078. : null,
  2079. datoms,
  2080. };
  2081. replayCaptureState.txLog.push(entry);
  2082. replayCaptureStoreEntry.txCapture.txLog.push(entry);
  2083. replayCaptureStoreEntry.txCapture.totalTx = replayCaptureStoreEntry.txCapture.txLog.length;
  2084. replayCaptureStoreEntry.updatedAt = Date.now();
  2085. }
  2086. }
  2087. } catch (_error) {
  2088. // keep capture best-effort
  2089. }
  2090. };
  2091. sinks.push(sink);
  2092. if (window[patchInstalledKey] !== true) {
  2093. const original = core.hookDb.bind(core);
  2094. core.hookDb = (type, payload, pluginId) => {
  2095. try {
  2096. const listeners = Array.isArray(window[sinkKey]) ? window[sinkKey] : [];
  2097. for (const listener of listeners) {
  2098. if (typeof listener === 'function') {
  2099. listener(type, payload);
  2100. }
  2101. }
  2102. } catch (_error) {
  2103. // keep hook best-effort
  2104. }
  2105. return original(type, payload, pluginId);
  2106. };
  2107. window[patchInstalledKey] = true;
  2108. }
  2109. replayCaptureState.installed = true;
  2110. replayCaptureState.enabled = true;
  2111. replayCaptureState.installReason = null;
  2112. replayCaptureStoreEntry.txCapture.installed = true;
  2113. replayCaptureStoreEntry.txCapture.enabled = true;
  2114. replayCaptureStoreEntry.txCapture.installReason = null;
  2115. replayCaptureStoreEntry.updatedAt = Date.now();
  2116. };
  2117. const flattenAnyObjects = (value, acc = []) => {
  2118. if (Array.isArray(value)) {
  2119. for (const item of value) flattenAnyObjects(item, acc);
  2120. return acc;
  2121. }
  2122. if (value && typeof value === 'object') {
  2123. acc.push(value);
  2124. }
  2125. return acc;
  2126. };
  2127. const normalizeSnapshotBlock = (block) => {
  2128. if (!block || typeof block !== 'object') return null;
  2129. const id = Number(readAny(block, ['id', 'db/id', ':db/id']));
  2130. const uuid = readAny(block, ['uuid', 'block/uuid', ':block/uuid']);
  2131. if (!Number.isInteger(id) || typeof uuid !== 'string' || uuid.length === 0) return null;
  2132. const parent = readAny(block, ['parent', 'block/parent', ':block/parent']);
  2133. const page = readAny(block, ['page', 'block/page', ':block/page']);
  2134. const parentId = Number(readAny(parent, ['id', 'db/id', ':db/id']));
  2135. const pageId = Number(readAny(page, ['id', 'db/id', ':db/id']));
  2136. const title = readAny(block, ['title', 'block/title', ':block/title']);
  2137. const name = readAny(block, ['name', 'block/name', ':block/name']);
  2138. const order = readAny(block, ['order', 'block/order', ':block/order']);
  2139. return {
  2140. id,
  2141. uuid,
  2142. parentId: Number.isInteger(parentId) ? parentId : null,
  2143. pageId: Number.isInteger(pageId) ? pageId : null,
  2144. title: typeof title === 'string' ? title : null,
  2145. name: typeof name === 'string' ? name : null,
  2146. order: typeof order === 'string' ? order : null,
  2147. };
  2148. };
  2149. const captureInitialDbSnapshot = async () => {
  2150. if (!replayCaptureEnabled) {
  2151. return {
  2152. ok: false,
  2153. reason: 'disabled by config',
  2154. blockCount: 0,
  2155. blocks: [],
  2156. };
  2157. }
  2158. if (typeof logseq.api.datascript_query !== 'function') {
  2159. return {
  2160. ok: false,
  2161. reason: 'datascript_query API unavailable',
  2162. blockCount: 0,
  2163. blocks: [],
  2164. };
  2165. }
  2166. try {
  2167. 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]]';
  2168. const raw = await logseq.api.datascript_query(query);
  2169. const objects = flattenAnyObjects(raw, []);
  2170. const blocks = objects
  2171. .map(normalizeSnapshotBlock)
  2172. .filter(Boolean);
  2173. const dedup = new Map();
  2174. for (const block of blocks) {
  2175. dedup.set(block.id, block);
  2176. }
  2177. const normalized = Array.from(dedup.values())
  2178. .sort((a, b) => a.id - b.id);
  2179. return {
  2180. ok: true,
  2181. blockCount: normalized.length,
  2182. blocks: normalized,
  2183. };
  2184. } catch (error) {
  2185. return {
  2186. ok: false,
  2187. reason: describeError(error),
  2188. blockCount: 0,
  2189. blocks: [],
  2190. };
  2191. }
  2192. };
  2193. const snapshotBlocksToStateMap = (blocks) => {
  2194. const stateMap = new Map();
  2195. if (!Array.isArray(blocks)) return stateMap;
  2196. for (const block of blocks) {
  2197. if (!block || typeof block !== 'object') continue;
  2198. const id = Number(block.id);
  2199. if (!Number.isInteger(id)) continue;
  2200. stateMap.set(id, {
  2201. id,
  2202. uuid: typeof block.uuid === 'string' ? block.uuid : null,
  2203. title: typeof block.title === 'string' ? block.title : null,
  2204. name: typeof block.name === 'string' ? block.name : null,
  2205. order: typeof block.order === 'string' ? block.order : null,
  2206. parentId: Number.isInteger(block.parentId) ? block.parentId : null,
  2207. pageId: Number.isInteger(block.pageId) ? block.pageId : null,
  2208. });
  2209. }
  2210. return stateMap;
  2211. };
  2212. const captureChecksumStateMap = async () => {
  2213. const snapshot = await captureInitialDbSnapshot();
  2214. return {
  2215. ok: snapshot.ok === true,
  2216. reason: snapshot.reason || null,
  2217. state: snapshotBlocksToStateMap(snapshot.blocks),
  2218. };
  2219. };
  2220. const replayDatomEntriesFromStateDiff = (beforeMap, afterMap) => {
  2221. const datoms = [];
  2222. const allIds = new Set();
  2223. for (const id of beforeMap.keys()) allIds.add(id);
  2224. for (const id of afterMap.keys()) allIds.add(id);
  2225. const scalarAttrs = [
  2226. ['uuid', ':block/uuid'],
  2227. ['title', ':block/title'],
  2228. ['name', ':block/name'],
  2229. ['order', ':block/order'],
  2230. ];
  2231. const refAttrs = [
  2232. ['parentId', ':block/parent'],
  2233. ['pageId', ':block/page'],
  2234. ];
  2235. for (const id of allIds) {
  2236. const before = beforeMap.get(id) || null;
  2237. const after = afterMap.get(id) || null;
  2238. for (const [key, attr] of scalarAttrs) {
  2239. const beforeValue = before ? before[key] : null;
  2240. const afterValue = after ? after[key] : null;
  2241. if (beforeValue === afterValue) continue;
  2242. if (typeof beforeValue === 'string') {
  2243. datoms.push({ e: id, a: attr, v: beforeValue, added: false });
  2244. }
  2245. if (typeof afterValue === 'string') {
  2246. datoms.push({ e: id, a: attr, v: afterValue, added: true });
  2247. }
  2248. }
  2249. for (const [key, attr] of refAttrs) {
  2250. const beforeValue = before ? before[key] : null;
  2251. const afterValue = after ? after[key] : null;
  2252. if (beforeValue === afterValue) continue;
  2253. if (Number.isInteger(beforeValue)) {
  2254. datoms.push({ e: id, a: attr, v: beforeValue, added: false });
  2255. }
  2256. if (Number.isInteger(afterValue)) {
  2257. datoms.push({ e: id, a: attr, v: afterValue, added: true });
  2258. }
  2259. }
  2260. }
  2261. return datoms;
  2262. };
  2263. const counts = {
  2264. add: 0,
  2265. save: 0,
  2266. inlineTag: 0,
  2267. emptyInlineTag: 0,
  2268. pageReference: 0,
  2269. blockReference: 0,
  2270. propertySet: 0,
  2271. batchSetProperty: 0,
  2272. propertyRemove: 0,
  2273. propertyValueDelete: 0,
  2274. templateApply: 0,
  2275. delete: 0,
  2276. move: 0,
  2277. moveUpDown: 0,
  2278. indent: 0,
  2279. outdent: 0,
  2280. undo: 0,
  2281. redo: 0,
  2282. copyPaste: 0,
  2283. copyPasteTreeToEmptyTarget: 0,
  2284. fallbackAdd: 0,
  2285. errors: 0,
  2286. };
  2287. const errors = [];
  2288. const operationLog = [];
  2289. const phaseTimeoutMs = Math.max(5000, Number(config.readyTimeoutMs || 0) + 5000);
  2290. const opReadTimeoutMs = Math.max(2000, Number(config.opTimeoutMs || 0) * 2);
  2291. installFatalWarningTrap();
  2292. installWsCapture();
  2293. clearFatalSignalState();
  2294. await withTimeout(waitForEditorReady(), phaseTimeoutMs, 'waitForEditorReady');
  2295. failIfFatalSignalSeen();
  2296. const anchor = await withTimeout(getAnchor(), phaseTimeoutMs, 'getAnchor');
  2297. await withTimeout(ensureClientRootBlock(anchor), phaseTimeoutMs, 'ensureClientRootBlock');
  2298. installReplayTxCapture();
  2299. const initialManaged = await withTimeout(listManagedBlocks(), phaseTimeoutMs, 'listManagedBlocks');
  2300. if (!initialManaged.length) {
  2301. await withTimeout(
  2302. logseq.api.insert_block(anchor.uuid, config.markerPrefix + ' seed', {
  2303. sibling: true,
  2304. before: false,
  2305. focus: false,
  2306. }),
  2307. phaseTimeoutMs,
  2308. 'insert seed block'
  2309. );
  2310. }
  2311. let outlinerOpCoverage = null;
  2312. try {
  2313. outlinerOpCoverage = await withTimeout(
  2314. (async () => {
  2315. await waitForOutlinerInteropReady();
  2316. return runAllOutlinerOpsCoveragePass(anchor);
  2317. })(),
  2318. Math.max(900000, phaseTimeoutMs * 6),
  2319. 'runAllOutlinerOpsCoveragePass'
  2320. );
  2321. } catch (error) {
  2322. const reason = describeError(error);
  2323. outlinerOpCoverage = {
  2324. ok: false,
  2325. total: 0,
  2326. passed: 0,
  2327. failed: expectedOutlinerOps.length,
  2328. stepTimeoutMs: null,
  2329. expectedOps: [...expectedOutlinerOps],
  2330. coveredOps: [],
  2331. missingOps: [...expectedOutlinerOps],
  2332. unexpectedOps: [],
  2333. failedOps: expectedOutlinerOps.map((op) => ({ op, error: reason })),
  2334. sample: expectedOutlinerOps.map((op) => ({
  2335. op,
  2336. ok: false,
  2337. error: reason,
  2338. durationMs: 0,
  2339. })),
  2340. reason,
  2341. };
  2342. }
  2343. const propertyOpsState = {
  2344. propertyName: (config.markerPrefix + 'sim-prop').toLowerCase().replace(/[^a-z0-9-]+/g, '-'),
  2345. tagName: (config.markerPrefix + 'sim-tag').replace(/[^a-zA-Z0-9_-]+/g, '-'),
  2346. propertyUuid: null,
  2347. propertyEid: null,
  2348. tagUuid: null,
  2349. ready: false,
  2350. };
  2351. const ensurePropertyOpsReady = async () => {
  2352. if (
  2353. propertyOpsState.ready &&
  2354. propertyOpsState.tagUuid &&
  2355. Number.isInteger(propertyOpsState.propertyEid)
  2356. ) {
  2357. return propertyOpsState;
  2358. }
  2359. const propertyEntity = await logseq.api.upsert_property(
  2360. propertyOpsState.propertyName,
  2361. { type: 'default', cardinality: 'many' },
  2362. {}
  2363. );
  2364. const propertyUuid =
  2365. getEntityUuid(propertyEntity) ||
  2366. getEntityUuid(await logseq.api.get_property(propertyOpsState.propertyName));
  2367. if (!propertyUuid) {
  2368. throw new Error('Failed to resolve property-op property uuid');
  2369. }
  2370. const propertyEid = await queryEidByUuid(propertyUuid);
  2371. if (!Number.isInteger(propertyEid)) {
  2372. throw new Error('Failed to resolve property-op property eid');
  2373. }
  2374. const tag = await logseq.api.create_tag(propertyOpsState.tagName, {});
  2375. const tagUuid =
  2376. tag?.uuid ||
  2377. tag?.['block/uuid'] ||
  2378. tag?.block?.uuid ||
  2379. null;
  2380. if (!tagUuid) {
  2381. throw new Error('Failed to create property-op tag');
  2382. }
  2383. propertyOpsState.propertyUuid = propertyUuid;
  2384. propertyOpsState.propertyEid = propertyEid;
  2385. propertyOpsState.tagUuid = tagUuid;
  2386. propertyOpsState.ready = true;
  2387. return propertyOpsState;
  2388. };
  2389. const templateOpsState = {
  2390. templateRootUuid: null,
  2391. templateRootEid: null,
  2392. ready: false,
  2393. };
  2394. const ensureTemplateOpsReady = async () => {
  2395. if (templateOpsState.ready && templateOpsState.templateRootUuid) {
  2396. const existing = await logseq.api.get_block(templateOpsState.templateRootUuid, { includeChildren: false });
  2397. if (existing?.uuid && Number.isInteger(templateOpsState.templateRootEid)) {
  2398. return templateOpsState;
  2399. }
  2400. }
  2401. const templateRoot = await logseq.api.insert_block(anchor.uuid, config.markerPrefix + ' template-root', {
  2402. sibling: true,
  2403. before: false,
  2404. focus: false,
  2405. });
  2406. const templateRootUuid = getEntityUuid(templateRoot);
  2407. if (!templateRootUuid) {
  2408. throw new Error('Failed to create template root block');
  2409. }
  2410. const templateChild = await logseq.api.insert_block(
  2411. templateRootUuid,
  2412. config.markerPrefix + ' template-child',
  2413. { sibling: false, before: false, focus: false }
  2414. );
  2415. if (!getEntityUuid(templateChild)) {
  2416. throw new Error('Failed to create template child block');
  2417. }
  2418. const templateRootEid = await queryEidByUuid(templateRootUuid);
  2419. if (!Number.isInteger(templateRootEid)) {
  2420. throw new Error('Failed to resolve template root eid');
  2421. }
  2422. templateOpsState.templateRootUuid = templateRootUuid;
  2423. templateOpsState.templateRootEid = templateRootEid;
  2424. templateOpsState.ready = true;
  2425. return templateOpsState;
  2426. };
  2427. const initialDb = await withTimeout(
  2428. captureInitialDbSnapshot(),
  2429. phaseTimeoutMs,
  2430. 'captureInitialDbSnapshot'
  2431. );
  2432. replayCaptureStoreEntry.initialDb = initialDb;
  2433. replayCaptureStoreEntry.updatedAt = Date.now();
  2434. let replaySnapshotState = {
  2435. ok: initialDb?.ok === true,
  2436. reason: initialDb?.reason || null,
  2437. state: snapshotBlocksToStateMap(initialDb?.blocks),
  2438. };
  2439. const scenarioEvents = [];
  2440. const isOfflineScenario = config.scenario === 'offline';
  2441. const recordScenarioEvent = (phase, status, error = null) => {
  2442. scenarioEvents.push({
  2443. phase,
  2444. status,
  2445. error,
  2446. createdAt: Date.now(),
  2447. });
  2448. };
  2449. const toggleScenarioNetwork = async (phase, eventType) => {
  2450. try {
  2451. window.dispatchEvent(new Event(eventType));
  2452. recordScenarioEvent(phase, eventType, null);
  2453. } catch (error) {
  2454. recordScenarioEvent(phase, 'error', describeError(error));
  2455. }
  2456. };
  2457. const appendReplayFallbackTxFromSnapshot = async (opIndex) => {
  2458. if (!replayCaptureEnabled) return;
  2459. const nextSnapshot = await captureChecksumStateMap();
  2460. if (!nextSnapshot || nextSnapshot.ok !== true || !nextSnapshot.state) {
  2461. replaySnapshotState = nextSnapshot;
  2462. return;
  2463. }
  2464. if (!replaySnapshotState || replaySnapshotState.ok !== true || !replaySnapshotState.state) {
  2465. replaySnapshotState = nextSnapshot;
  2466. return;
  2467. }
  2468. const alreadyCaptured = replayCaptureState.txLog.some((entry) => entry?.opIndex === opIndex);
  2469. const datoms = replayDatomEntriesFromStateDiff(replaySnapshotState.state, nextSnapshot.state);
  2470. if (!alreadyCaptured && datoms.length > 0) {
  2471. const entry = {
  2472. capturedAt: Date.now(),
  2473. opIndex,
  2474. source: 'snapshot-diff',
  2475. datoms,
  2476. };
  2477. replayCaptureState.txLog.push(entry);
  2478. replayCaptureStoreEntry.txCapture.txLog.push(entry);
  2479. replayCaptureStoreEntry.txCapture.totalTx = replayCaptureStoreEntry.txCapture.txLog.length;
  2480. replayCaptureStoreEntry.updatedAt = Date.now();
  2481. }
  2482. replaySnapshotState = nextSnapshot;
  2483. };
  2484. let executed = 0;
  2485. if (isOfflineScenario) {
  2486. await toggleScenarioNetwork('beforePlanOps', 'offline');
  2487. await sleep(20);
  2488. }
  2489. try {
  2490. for (let i = 0; i < config.plan.length; i += 1) {
  2491. failIfFatalSignalSeen();
  2492. const requested = config.plan[i];
  2493. const operable = await withTimeout(
  2494. listOperableBlocks(),
  2495. opReadTimeoutMs,
  2496. 'listOperableBlocks before operation'
  2497. );
  2498. let operation = chooseRunnableOperation(requested, operable.length);
  2499. if (operation !== requested) {
  2500. counts.fallbackAdd += 1;
  2501. }
  2502. try {
  2503. await sleep(Math.floor(nextRandom() * 10));
  2504. replayCaptureState.currentOpIndex = i;
  2505. const runOperation = async () => {
  2506. if (operation === 'add') {
  2507. const target = operable.length > 0 ? randomItem(operable) : anchor;
  2508. const content = nextRandom() < 0.2 ? '' : config.markerPrefix + ' add-' + i;
  2509. const asChild = operable.length > 0 && nextRandom() < 0.35;
  2510. const inserted = await logseq.api.insert_block(target.uuid, content, {
  2511. sibling: !asChild,
  2512. before: false,
  2513. focus: false,
  2514. });
  2515. return {
  2516. kind: 'add',
  2517. targetUuid: target.uuid || null,
  2518. insertedUuid: inserted?.uuid || null,
  2519. content,
  2520. sibling: !asChild,
  2521. before: false,
  2522. };
  2523. }
  2524. if (operation === 'save') {
  2525. let candidate = randomItem(
  2526. operable.filter((block) => block?.uuid && !isClientRootBlock(block))
  2527. );
  2528. if (!candidate?.uuid) {
  2529. const target = operable.length > 0 ? randomItem(operable) : anchor;
  2530. candidate = await logseq.api.insert_block(target.uuid, config.markerPrefix + ' save-target-' + i, {
  2531. sibling: true,
  2532. before: false,
  2533. focus: false,
  2534. });
  2535. if (!candidate?.uuid) {
  2536. throw new Error('Failed to create save candidate block');
  2537. }
  2538. }
  2539. const latest = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  2540. const previousContent = typeof latest?.content === 'string'
  2541. ? latest.content
  2542. : (typeof candidate.content === 'string' ? candidate.content : '');
  2543. const nextContent = previousContent.length > 0
  2544. ? previousContent + ' ' + config.markerPrefix + ' save-' + i
  2545. : config.markerPrefix + ' save-' + i;
  2546. await logseq.api.update_block(candidate.uuid, nextContent);
  2547. return {
  2548. kind: 'save',
  2549. candidateUuid: candidate.uuid || null,
  2550. previousContentLength: previousContent.length,
  2551. nextContentLength: nextContent.length,
  2552. };
  2553. }
  2554. if (operation === 'inlineTag') {
  2555. const candidate = randomItem(
  2556. operable.filter((block) => block?.uuid && !isClientRootBlock(block))
  2557. ) || anchor;
  2558. const latest = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  2559. const previousContent = typeof latest?.content === 'string'
  2560. ? latest.content
  2561. : (typeof candidate.content === 'string' ? candidate.content : '');
  2562. const tagName = (config.markerPrefix + 'inline-tag-' + i).replace(/[^a-zA-Z0-9_-]+/g, '-');
  2563. const token = '#[[' + tagName + ']]';
  2564. const nextContent = previousContent.length > 0
  2565. ? previousContent + ' ' + token
  2566. : token;
  2567. await logseq.api.update_block(candidate.uuid, nextContent);
  2568. return {
  2569. kind: 'inlineTag',
  2570. candidateUuid: candidate.uuid || null,
  2571. token,
  2572. };
  2573. }
  2574. if (operation === 'emptyInlineTag') {
  2575. const target = randomItem(
  2576. operable.filter((block) => block?.uuid && !isClientRootBlock(block))
  2577. ) || anchor;
  2578. const tagNameRaw = (config.markerPrefix + 'tag-' + i).replace(/[^a-zA-Z0-9_-]+/g, '-');
  2579. const tagName = tagNameRaw.replace(/^-+/, '') || ('tag-' + i);
  2580. const token = '#' + tagName;
  2581. const inserted = await logseq.api.insert_block(target.uuid, token, {
  2582. sibling: true,
  2583. before: false,
  2584. focus: false,
  2585. });
  2586. return {
  2587. kind: 'emptyInlineTag',
  2588. targetUuid: target.uuid || null,
  2589. insertedUuid: inserted?.uuid || null,
  2590. token,
  2591. };
  2592. }
  2593. if (operation === 'pageReference') {
  2594. const candidate = randomItem(
  2595. operable.filter((block) => block?.uuid && !isClientRootBlock(block))
  2596. ) || anchor;
  2597. const latest = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  2598. const previousContent = typeof latest?.content === 'string'
  2599. ? latest.content
  2600. : (typeof candidate.content === 'string' ? candidate.content : '');
  2601. const refPageName = (config.markerPrefix + 'page-ref-' + i).replace(/[^a-zA-Z0-9 _-]+/g, '-').trim();
  2602. await logseq.api.create_page(refPageName, null, { redirect: false });
  2603. const token = '[[' + refPageName + ']]';
  2604. const nextContent = previousContent.length > 0
  2605. ? previousContent + ' ' + token
  2606. : token;
  2607. await logseq.api.update_block(candidate.uuid, nextContent);
  2608. return {
  2609. kind: 'pageReference',
  2610. candidateUuid: candidate.uuid || null,
  2611. refPageName,
  2612. };
  2613. }
  2614. if (operation === 'blockReference') {
  2615. let blockPool = operable.filter((block) => block?.uuid && !isClientRootBlock(block));
  2616. if (blockPool.length < 2) {
  2617. const seedTarget = blockPool.length > 0 ? blockPool[0] : anchor;
  2618. const inserted = await logseq.api.insert_block(
  2619. seedTarget.uuid,
  2620. config.markerPrefix + ' block-ref-target-' + i,
  2621. { sibling: true, before: false, focus: false }
  2622. );
  2623. if (!inserted?.uuid) {
  2624. throw new Error('Failed to create block-ref target');
  2625. }
  2626. blockPool = (await listOperableBlocks()).filter(
  2627. (block) => block?.uuid && !isClientRootBlock(block)
  2628. );
  2629. }
  2630. if (blockPool.length < 2) {
  2631. throw new Error('Not enough blocks for block reference');
  2632. }
  2633. const [target, source] = pickRandomGroup(blockPool, 2, 2);
  2634. const latestTarget = await logseq.api.get_block(target.uuid, { includeChildren: false });
  2635. const previousContent = typeof latestTarget?.content === 'string'
  2636. ? latestTarget.content
  2637. : (typeof target.content === 'string' ? target.content : '');
  2638. const token = '((' + source.uuid + '))';
  2639. const nextContent = previousContent.length > 0
  2640. ? previousContent + ' ' + token
  2641. : token;
  2642. await logseq.api.update_block(target.uuid, nextContent);
  2643. return {
  2644. kind: 'blockReference',
  2645. targetUuid: target.uuid || null,
  2646. sourceUuid: source.uuid || null,
  2647. };
  2648. }
  2649. if (operation === 'propertySet') {
  2650. const state = await ensurePropertyOpsReady();
  2651. const candidate = randomItem(operable.filter((block) => block?.uuid)) || anchor;
  2652. const value = config.markerPrefix + ' prop-set-' + i;
  2653. await logseq.api.upsert_block_property(candidate.uuid, state.propertyName, value, {});
  2654. return {
  2655. kind: 'propertySet',
  2656. candidateUuid: candidate.uuid || null,
  2657. propertyName: state.propertyName,
  2658. };
  2659. }
  2660. if (operation === 'batchSetProperty') {
  2661. const state = await ensurePropertyOpsReady();
  2662. if (!Number.isInteger(state.propertyEid)) {
  2663. throw new Error('Property entity id is unavailable for batchSetProperty');
  2664. }
  2665. let targets = operable
  2666. .filter((block) => block?.uuid && !isClientRootBlock(block))
  2667. .map((block) => ({ uuid: block.uuid }));
  2668. while (targets.length < 2) {
  2669. const parent = targets.length > 0 ? targets[0] : anchor;
  2670. const inserted = await logseq.api.insert_block(
  2671. parent.uuid,
  2672. config.markerPrefix + ' batch-prop-target-' + i + '-' + targets.length,
  2673. { sibling: true, before: false, focus: false }
  2674. );
  2675. if (!inserted?.uuid) {
  2676. throw new Error('Failed to create batchSetProperty target');
  2677. }
  2678. targets.push({ uuid: inserted.uuid });
  2679. }
  2680. const selectedTargets = pickRandomGroup(targets, 2, Math.min(4, targets.length));
  2681. const selectedEids = [];
  2682. const selectedUuids = [];
  2683. for (const target of selectedTargets) {
  2684. if (!target?.uuid) continue;
  2685. const eid = await queryEidByUuid(target.uuid);
  2686. if (Number.isInteger(eid)) {
  2687. selectedEids.push(eid);
  2688. selectedUuids.push(target.uuid);
  2689. }
  2690. }
  2691. if (selectedEids.length < 2) {
  2692. throw new Error('Failed to resolve multiple target eids for batchSetProperty');
  2693. }
  2694. const value = config.markerPrefix + ' batch-set-' + i;
  2695. await applyRawOutlinerOp('batch-set-property', [
  2696. selectedEids,
  2697. state.propertyEid,
  2698. value,
  2699. {},
  2700. ]);
  2701. return {
  2702. kind: 'batchSetProperty',
  2703. propertyName: state.propertyName,
  2704. targetCount: selectedUuids.length,
  2705. targetUuids: selectedUuids,
  2706. };
  2707. }
  2708. if (operation === 'propertyRemove') {
  2709. const state = await ensurePropertyOpsReady();
  2710. const candidate = randomItem(operable.filter((block) => block?.uuid)) || anchor;
  2711. await logseq.api.remove_block_property(candidate.uuid, state.propertyName);
  2712. return {
  2713. kind: 'propertyRemove',
  2714. candidateUuid: candidate.uuid || null,
  2715. propertyName: state.propertyName,
  2716. };
  2717. }
  2718. if (operation === 'propertyValueDelete') {
  2719. const state = await ensurePropertyOpsReady();
  2720. const candidate = randomItem(operable.filter((block) => block?.uuid)) || anchor;
  2721. await logseq.api.add_block_tag(candidate.uuid, state.tagUuid);
  2722. await logseq.api.remove_block_tag(candidate.uuid, state.tagUuid);
  2723. return {
  2724. kind: 'propertyValueDelete',
  2725. candidateUuid: candidate.uuid || null,
  2726. tagUuid: state.tagUuid,
  2727. };
  2728. }
  2729. if (operation === 'templateApply') {
  2730. const state = await ensureTemplateOpsReady();
  2731. if (!Number.isInteger(state.templateRootEid)) {
  2732. throw new Error('Template root eid is unavailable for templateApply');
  2733. }
  2734. const target = randomItem(
  2735. operable.filter((block) => block?.uuid && !isClientRootBlock(block))
  2736. ) || anchor;
  2737. const targetEid = await queryEidByUuid(target.uuid);
  2738. if (!Number.isInteger(targetEid)) {
  2739. throw new Error('Failed to resolve templateApply target eid');
  2740. }
  2741. await applyRawOutlinerOp('apply-template', [
  2742. state.templateRootEid,
  2743. targetEid,
  2744. { sibling: true },
  2745. ]);
  2746. return {
  2747. kind: 'templateApply',
  2748. templateRootUuid: state.templateRootUuid,
  2749. targetUuid: target.uuid || null,
  2750. };
  2751. }
  2752. if (operation === 'copyPaste') {
  2753. const pageBlocks = await listPageBlocks();
  2754. const copyPool = (operable.length > 0 ? operable : pageBlocks).filter((b) => b?.uuid);
  2755. if (copyPool.length === 0) {
  2756. throw new Error('No blocks available for copyPaste');
  2757. }
  2758. const source = randomItem(copyPool);
  2759. const target = randomItem(copyPool);
  2760. await logseq.api.select_block(source.uuid);
  2761. await logseq.api.invoke_external_command('logseq.editor/copy');
  2762. const latestSource = await logseq.api.get_block(source.uuid);
  2763. const sourceContent = latestSource?.content || source.content || '';
  2764. const copiedContent =
  2765. config.markerPrefix + ' copy-' + i + (sourceContent ? ' :: ' + sourceContent : '');
  2766. const inserted = await logseq.api.insert_block(target.uuid, copiedContent, {
  2767. sibling: true,
  2768. before: false,
  2769. focus: false,
  2770. });
  2771. return {
  2772. kind: 'copyPaste',
  2773. sourceUuid: source.uuid || null,
  2774. targetUuid: target.uuid || null,
  2775. insertedUuid: inserted?.uuid || null,
  2776. copiedContent,
  2777. };
  2778. }
  2779. if (operation === 'copyPasteTreeToEmptyTarget') {
  2780. const pageBlocks = await listPageBlocks();
  2781. const treePool = (operable.length >= 2 ? operable : pageBlocks).filter((b) => b?.uuid);
  2782. if (treePool.length < 2) {
  2783. throw new Error('Not enough blocks available for multi-block copy');
  2784. }
  2785. const sources = pickRandomGroup(treePool, 2, 4);
  2786. const sourceTrees = [];
  2787. for (const source of sources) {
  2788. const sourceTree = await logseq.api.get_block(source.uuid, { includeChildren: true });
  2789. if (sourceTree?.uuid) {
  2790. sourceTrees.push(sourceTree);
  2791. }
  2792. }
  2793. if (sourceTrees.length === 0) {
  2794. throw new Error('Failed to load source tree blocks');
  2795. }
  2796. const treeTarget = operable.length > 0 ? randomItem(operable) : anchor;
  2797. const emptyTarget = await logseq.api.insert_block(treeTarget.uuid, '', {
  2798. sibling: true,
  2799. before: false,
  2800. focus: false,
  2801. });
  2802. if (!emptyTarget?.uuid) {
  2803. throw new Error('Failed to create empty target block');
  2804. }
  2805. await logseq.api.update_block(emptyTarget.uuid, '');
  2806. const payload = sourceTrees.map((tree, idx) => {
  2807. const node = toBatchTree(tree);
  2808. const origin = typeof node.content === 'string' && node.content.length > 0
  2809. ? ' :: ' + node.content
  2810. : '';
  2811. node.content = config.markerPrefix + ' tree-copy-' + i + '-' + idx + origin;
  2812. return node;
  2813. });
  2814. let fallbackToSingleTree = false;
  2815. try {
  2816. await logseq.api.insert_batch_block(emptyTarget.uuid, payload, { sibling: false });
  2817. } catch (_error) {
  2818. fallbackToSingleTree = true;
  2819. for (const tree of sourceTrees) {
  2820. await logseq.api.insert_batch_block(emptyTarget.uuid, toBatchTree(tree), { sibling: false });
  2821. }
  2822. }
  2823. return {
  2824. kind: 'copyPasteTreeToEmptyTarget',
  2825. treeTargetUuid: treeTarget.uuid || null,
  2826. emptyTargetUuid: emptyTarget.uuid || null,
  2827. sourceUuids: sourceTrees.map((tree) => tree?.uuid).filter(Boolean),
  2828. payloadSize: payload.length,
  2829. fallbackToSingleTree,
  2830. };
  2831. }
  2832. if (operation === 'move') {
  2833. const source = randomItem(operable);
  2834. const candidates = operable.filter((block) => block.uuid !== source.uuid);
  2835. const target = randomItem(candidates);
  2836. const before = nextRandom() < 0.5;
  2837. await logseq.api.move_block(source.uuid, target.uuid, {
  2838. before,
  2839. children: false,
  2840. });
  2841. return {
  2842. kind: 'move',
  2843. sourceUuid: source.uuid || null,
  2844. targetUuid: target.uuid || null,
  2845. before,
  2846. children: false,
  2847. };
  2848. }
  2849. if (operation === 'moveUpDown') {
  2850. const up = nextRandom() < 0.5;
  2851. const prepared = await ensureMoveUpDownCandidate(operable, anchor, i, up);
  2852. const candidate = prepared?.candidate;
  2853. if (!candidate?.uuid) {
  2854. throw new Error('No valid move-up-down candidate');
  2855. }
  2856. await logseq.api.select_block(candidate.uuid);
  2857. const command = up
  2858. ? 'logseq.editor/move-block-up'
  2859. : 'logseq.editor/move-block-down';
  2860. await logseq.api.invoke_external_command(command);
  2861. return {
  2862. kind: 'moveUpDown',
  2863. candidateUuid: candidate.uuid || null,
  2864. siblingUuid: prepared?.siblingUuid || null,
  2865. direction: up ? 'up' : 'down',
  2866. command,
  2867. };
  2868. }
  2869. if (operation === 'indent') {
  2870. const candidate = await ensureIndentCandidate(operable, anchor, i);
  2871. const prevUuid = await getPreviousSiblingUuid(candidate.uuid);
  2872. if (!prevUuid) {
  2873. throw new Error('No previous sibling for indent candidate');
  2874. }
  2875. await logseq.api.move_block(candidate.uuid, prevUuid, {
  2876. before: false,
  2877. children: true,
  2878. });
  2879. return {
  2880. kind: 'indent',
  2881. candidateUuid: candidate.uuid || null,
  2882. targetUuid: prevUuid,
  2883. before: false,
  2884. children: true,
  2885. };
  2886. }
  2887. if (operation === 'outdent') {
  2888. const candidate = await ensureOutdentCandidate(operable, anchor, i);
  2889. const full = await logseq.api.get_block(candidate.uuid, { includeChildren: false });
  2890. const parentId = full?.parent?.id;
  2891. const pageId = full?.page?.id;
  2892. if (!parentId || !pageId || parentId === pageId) {
  2893. throw new Error('Outdent candidate is not nested');
  2894. }
  2895. const parent = await logseq.api.get_block(parentId, { includeChildren: false });
  2896. if (!parent?.uuid) {
  2897. throw new Error('Cannot resolve parent block for outdent');
  2898. }
  2899. await logseq.api.move_block(candidate.uuid, parent.uuid, {
  2900. before: false,
  2901. children: false,
  2902. });
  2903. return {
  2904. kind: 'outdent',
  2905. candidateUuid: candidate.uuid || null,
  2906. targetUuid: parent.uuid || null,
  2907. before: false,
  2908. children: false,
  2909. };
  2910. }
  2911. if (operation === 'delete') {
  2912. const candidates = operable.filter((block) => block.uuid !== anchor.uuid && !isClientRootBlock(block));
  2913. const victimPool = candidates.length > 0 ? candidates : operable;
  2914. const victim = randomItem(victimPool);
  2915. if (isClientRootBlock(victim)) {
  2916. throw new Error('Skip deleting protected client root block');
  2917. }
  2918. await logseq.api.remove_block(victim.uuid);
  2919. return {
  2920. kind: 'delete',
  2921. victimUuid: victim.uuid || null,
  2922. };
  2923. }
  2924. if (operation === 'undo') {
  2925. await logseq.api.invoke_external_command('logseq.editor/undo');
  2926. await sleep(config.undoRedoDelayMs);
  2927. return { kind: 'undo' };
  2928. }
  2929. if (operation === 'redo') {
  2930. await logseq.api.invoke_external_command('logseq.editor/redo');
  2931. await sleep(config.undoRedoDelayMs);
  2932. return { kind: 'redo' };
  2933. }
  2934. return { kind: operation };
  2935. };
  2936. const opDetail = await withTimeout(runOperation(), config.opTimeoutMs, operation + ' operation');
  2937. failIfFatalSignalSeen();
  2938. try {
  2939. await withTimeout(
  2940. appendReplayFallbackTxFromSnapshot(i),
  2941. opReadTimeoutMs,
  2942. 'appendReplayFallbackTxFromSnapshot'
  2943. );
  2944. } catch (_error) {
  2945. // best-effort fallback capture
  2946. }
  2947. counts[operation] += 1;
  2948. executed += 1;
  2949. const opEntry = { index: i, requested, executedAs: operation, detail: opDetail || null };
  2950. operationLog.push(opEntry);
  2951. replayCaptureStoreEntry.opLog.push(opEntry);
  2952. replayCaptureStoreEntry.updatedAt = Date.now();
  2953. } catch (error) {
  2954. counts.errors += 1;
  2955. errors.push({
  2956. index: i,
  2957. requested,
  2958. attempted: operation,
  2959. message: String(error?.message || error),
  2960. });
  2961. try {
  2962. const recoveryOperable = await withTimeout(
  2963. listOperableBlocks(),
  2964. opReadTimeoutMs,
  2965. 'listOperableBlocks for recovery'
  2966. );
  2967. const target = recoveryOperable.length > 0 ? randomItem(recoveryOperable) : anchor;
  2968. await withTimeout(
  2969. logseq.api.insert_block(target.uuid, config.markerPrefix + ' recovery-' + i, {
  2970. sibling: true,
  2971. before: false,
  2972. focus: false,
  2973. }),
  2974. opReadTimeoutMs,
  2975. 'insert recovery block'
  2976. );
  2977. counts.add += 1;
  2978. executed += 1;
  2979. try {
  2980. await withTimeout(
  2981. appendReplayFallbackTxFromSnapshot(i),
  2982. opReadTimeoutMs,
  2983. 'appendReplayFallbackTxFromSnapshot-recovery'
  2984. );
  2985. } catch (_error) {
  2986. // best-effort fallback capture
  2987. }
  2988. const opEntry = {
  2989. index: i,
  2990. requested,
  2991. executedAs: 'add',
  2992. detail: {
  2993. kind: 'recovery-add',
  2994. targetUuid: target.uuid || null,
  2995. },
  2996. };
  2997. operationLog.push(opEntry);
  2998. replayCaptureStoreEntry.opLog.push(opEntry);
  2999. replayCaptureStoreEntry.updatedAt = Date.now();
  3000. } catch (recoveryError) {
  3001. errors.push({
  3002. index: i,
  3003. requested,
  3004. attempted: 'recovery-add',
  3005. message: String(recoveryError?.message || recoveryError),
  3006. });
  3007. break;
  3008. }
  3009. } finally {
  3010. replayCaptureState.currentOpIndex = null;
  3011. }
  3012. }
  3013. } finally {
  3014. if (isOfflineScenario) {
  3015. await toggleScenarioNetwork('afterPlanOps', 'online');
  3016. await sleep(20);
  3017. }
  3018. }
  3019. let checksum = null;
  3020. const warnings = [];
  3021. failIfFatalSignalSeen();
  3022. if (config.verifyChecksum) {
  3023. try {
  3024. checksum = await withTimeout(
  3025. runChecksumDiagnostics(),
  3026. Math.max(
  3027. 45000,
  3028. Number(config.syncSettleTimeoutMs || 0) +
  3029. Number(config.readyTimeoutMs || 0) +
  3030. 10000
  3031. ),
  3032. 'runChecksumDiagnostics'
  3033. );
  3034. } catch (error) {
  3035. checksum = {
  3036. ok: false,
  3037. reason: String(error?.message || error),
  3038. timedOut: true,
  3039. };
  3040. }
  3041. if (!checksum.ok) {
  3042. warnings.push({
  3043. index: config.plan.length,
  3044. requested: 'verifyChecksum',
  3045. attempted: 'verifyChecksum',
  3046. message: checksum.reason || 'checksum mismatch',
  3047. checksum,
  3048. });
  3049. }
  3050. }
  3051. const finalManaged = await withTimeout(listManagedBlocks(), phaseTimeoutMs, 'final listManagedBlocks');
  3052. replayCaptureState.enabled = false;
  3053. const replayTxCapture = {
  3054. enabled: replayCaptureEnabled,
  3055. installed: replayCaptureState.installed === true,
  3056. installReason: replayCaptureState.installReason,
  3057. totalTx: replayCaptureState.txLog.length,
  3058. txLog: replayCaptureState.txLog,
  3059. };
  3060. replayCaptureStoreEntry.txCapture = replayTxCapture;
  3061. replayCaptureStoreEntry.updatedAt = Date.now();
  3062. return {
  3063. ok: errors.length === 0,
  3064. requestedOps: config.plan.length,
  3065. executedOps: executed,
  3066. counts,
  3067. markerPrefix: config.markerPrefix,
  3068. anchorUuid: anchor.uuid,
  3069. finalManagedCount: finalManaged.length,
  3070. sampleManaged: finalManaged.slice(0, 5).map((block) => ({
  3071. uuid: block.uuid,
  3072. content: block.content,
  3073. })),
  3074. errorCount: errors.length,
  3075. errors: errors.slice(0, 20),
  3076. warnings: warnings.slice(0, 20),
  3077. rtcLogs: getRtcLogList(),
  3078. consoleLogs: Array.isArray(consoleCaptureEntry) ? [...consoleCaptureEntry] : [],
  3079. wsMessages: {
  3080. installed: wsCaptureEntry?.installed === true,
  3081. installReason: wsCaptureEntry?.installReason || null,
  3082. outbound: Array.isArray(wsCaptureEntry?.outbound) ? [...wsCaptureEntry.outbound] : [],
  3083. inbound: Array.isArray(wsCaptureEntry?.inbound) ? [...wsCaptureEntry.inbound] : [],
  3084. },
  3085. requestedPlan: Array.isArray(config.plan) ? [...config.plan] : [],
  3086. opLog: operationLog,
  3087. opLogSample: operationLog.slice(0, 20),
  3088. scenario: config.scenario || 'online',
  3089. scenarioEvents: scenarioEvents.slice(0, 20),
  3090. outlinerOpCoverage,
  3091. initialDb,
  3092. txCapture: replayTxCapture,
  3093. checksum,
  3094. };
  3095. })())()`;
  3096. }
  3097. function buildCleanupTodayPageProgram(config = {}) {
  3098. const cleanupConfig = {
  3099. cleanupTodayPage: true,
  3100. ...(config || {}),
  3101. };
  3102. return `(() => (async () => {
  3103. const config = ${JSON.stringify(cleanupConfig)};
  3104. const asPageName = (pageLike) => {
  3105. if (typeof pageLike === 'string' && pageLike.length > 0) return pageLike;
  3106. if (!pageLike || typeof pageLike !== 'object') return null;
  3107. if (typeof pageLike.name === 'string' && pageLike.name.length > 0) return pageLike.name;
  3108. if (typeof pageLike.originalName === 'string' && pageLike.originalName.length > 0) return pageLike.originalName;
  3109. if (typeof pageLike.title === 'string' && pageLike.title.length > 0) return pageLike.title;
  3110. return null;
  3111. };
  3112. const purgePageBlocks = async (pageName) => {
  3113. if (!pageName) {
  3114. return { ok: false, pageName, reason: 'empty page name' };
  3115. }
  3116. if (!globalThis.logseq?.api?.get_page_blocks_tree || !globalThis.logseq?.api?.remove_block) {
  3117. return { ok: false, pageName, reason: 'page block APIs unavailable' };
  3118. }
  3119. let tree = [];
  3120. try {
  3121. tree = await logseq.api.get_page_blocks_tree(pageName);
  3122. } catch (error) {
  3123. return { ok: false, pageName, reason: 'failed to read page tree: ' + String(error?.message || error) };
  3124. }
  3125. const topLevel = Array.isArray(tree)
  3126. ? tree.map((block) => block?.uuid).filter(Boolean)
  3127. : [];
  3128. for (const uuid of topLevel) {
  3129. try {
  3130. await logseq.api.remove_block(uuid);
  3131. } catch (_error) {
  3132. // best-effort cleanup; continue deleting remaining blocks
  3133. }
  3134. }
  3135. return {
  3136. ok: true,
  3137. pageName,
  3138. removedBlocks: topLevel.length,
  3139. };
  3140. };
  3141. try {
  3142. const pages = [];
  3143. if (!globalThis.logseq?.api?.get_today_page) {
  3144. return { ok: false, reason: 'today page API unavailable' };
  3145. }
  3146. const today = await logseq.api.get_today_page();
  3147. const todayName = asPageName(today);
  3148. if (todayName) {
  3149. pages.push(todayName);
  3150. }
  3151. const uniquePages = Array.from(new Set(pages.filter(Boolean)));
  3152. const pageResults = [];
  3153. for (const pageName of uniquePages) {
  3154. const pageResult = await purgePageBlocks(pageName);
  3155. let deleted = false;
  3156. let deleteError = null;
  3157. if (globalThis.logseq?.api?.delete_page) {
  3158. try {
  3159. await logseq.api.delete_page(pageName);
  3160. deleted = true;
  3161. } catch (error) {
  3162. deleteError = String(error?.message || error);
  3163. }
  3164. }
  3165. pageResults.push({
  3166. ...pageResult,
  3167. deleted,
  3168. deleteError,
  3169. });
  3170. }
  3171. return {
  3172. ok: pageResults.every((item) => item.ok),
  3173. pages: pageResults,
  3174. };
  3175. } catch (error) {
  3176. return { ok: false, reason: String(error?.message || error) };
  3177. }
  3178. })())()`;
  3179. }
  3180. function buildGraphBootstrapProgram(config) {
  3181. return `(() => (async () => {
  3182. const config = ${JSON.stringify(config)};
  3183. const lower = (value) => String(value || '').toLowerCase();
  3184. const targetGraphLower = lower(config.graphName);
  3185. const stateKey = '__logseqOpBootstrapState';
  3186. const state = (window[stateKey] && typeof window[stateKey] === 'object') ? window[stateKey] : {};
  3187. window[stateKey] = state;
  3188. if (state.targetGraph !== config.graphName || state.runId !== config.runId) {
  3189. state.initialGraphName = null;
  3190. state.initialRepoName = null;
  3191. state.initialTargetMatched = null;
  3192. state.passwordAttempts = 0;
  3193. state.refreshCount = 0;
  3194. state.graphDetected = false;
  3195. state.graphCardClicked = false;
  3196. state.passwordSubmitted = false;
  3197. state.actionTriggered = false;
  3198. state.gotoGraphsOk = false;
  3199. state.gotoGraphsError = null;
  3200. state.downloadStarted = false;
  3201. state.downloadCompleted = false;
  3202. state.downloadCompletionSource = null;
  3203. state.lastDownloadLog = null;
  3204. state.lastRefreshAt = 0;
  3205. state.lastGraphClickAt = 0;
  3206. state.targetStateStableHits = 0;
  3207. state.switchAttempts = 0;
  3208. }
  3209. state.runId = config.runId;
  3210. state.targetGraph = config.graphName;
  3211. if (typeof state.passwordAttempts !== 'number') state.passwordAttempts = 0;
  3212. if (typeof state.refreshCount !== 'number') state.refreshCount = 0;
  3213. if (typeof state.graphDetected !== 'boolean') state.graphDetected = false;
  3214. if (typeof state.graphCardClicked !== 'boolean') state.graphCardClicked = false;
  3215. if (typeof state.passwordSubmitted !== 'boolean') state.passwordSubmitted = false;
  3216. if (typeof state.actionTriggered !== 'boolean') state.actionTriggered = false;
  3217. if (typeof state.gotoGraphsOk !== 'boolean') state.gotoGraphsOk = false;
  3218. if (typeof state.gotoGraphsError !== 'string' && state.gotoGraphsError !== null) state.gotoGraphsError = null;
  3219. if (typeof state.downloadStarted !== 'boolean') state.downloadStarted = false;
  3220. if (typeof state.downloadCompleted !== 'boolean') state.downloadCompleted = false;
  3221. if (typeof state.downloadCompletionSource !== 'string' && state.downloadCompletionSource !== null) {
  3222. state.downloadCompletionSource = null;
  3223. }
  3224. if (typeof state.lastDownloadLog !== 'object' && state.lastDownloadLog !== null) {
  3225. state.lastDownloadLog = null;
  3226. }
  3227. if (typeof state.initialRepoName !== 'string' && state.initialRepoName !== null) {
  3228. state.initialRepoName = null;
  3229. }
  3230. if (typeof state.initialTargetMatched !== 'boolean' && state.initialTargetMatched !== null) {
  3231. state.initialTargetMatched = null;
  3232. }
  3233. if (typeof state.lastRefreshAt !== 'number') {
  3234. state.lastRefreshAt = 0;
  3235. }
  3236. if (typeof state.lastGraphClickAt !== 'number') {
  3237. state.lastGraphClickAt = 0;
  3238. }
  3239. if (typeof state.targetStateStableHits !== 'number') {
  3240. state.targetStateStableHits = 0;
  3241. }
  3242. if (typeof state.switchAttempts !== 'number') {
  3243. state.switchAttempts = 0;
  3244. }
  3245. const setInputValue = (input, value) => {
  3246. if (!input) return;
  3247. const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
  3248. if (setter) {
  3249. setter.call(input, value);
  3250. } else {
  3251. input.value = value;
  3252. }
  3253. input.dispatchEvent(new Event('input', { bubbles: true }));
  3254. input.dispatchEvent(new Event('change', { bubbles: true }));
  3255. };
  3256. const dispatchClick = (node) => {
  3257. if (!(node instanceof HTMLElement)) return false;
  3258. try {
  3259. node.scrollIntoView({ block: 'center', inline: 'center' });
  3260. } catch (_error) {
  3261. // ignore scroll failures
  3262. }
  3263. try {
  3264. node.focus();
  3265. } catch (_error) {
  3266. // ignore focus failures
  3267. }
  3268. try {
  3269. node.click();
  3270. return true;
  3271. } catch (_error) {
  3272. // fall back to explicit events
  3273. }
  3274. node.dispatchEvent(new MouseEvent('mousedown', { view: window, bubbles: true, cancelable: true }));
  3275. node.dispatchEvent(new MouseEvent('mouseup', { view: window, bubbles: true, cancelable: true }));
  3276. node.dispatchEvent(new MouseEvent('click', { view: window, bubbles: true, cancelable: true }));
  3277. return true;
  3278. };
  3279. const graphNameMatchesTarget = (graphName) => {
  3280. const value = lower(graphName);
  3281. if (!value) return false;
  3282. return (
  3283. value === targetGraphLower ||
  3284. value.endsWith('/' + targetGraphLower) ||
  3285. value.endsWith('_' + targetGraphLower) ||
  3286. value.includes('logseq_db_' + targetGraphLower)
  3287. );
  3288. };
  3289. const stateMatchesTarget = (repoName, graphName) => {
  3290. const hasRepo = typeof repoName === 'string' && repoName.length > 0;
  3291. const hasGraph = typeof graphName === 'string' && graphName.length > 0;
  3292. const repoMatches = hasRepo ? graphNameMatchesTarget(repoName) : false;
  3293. const graphMatches = hasGraph ? graphNameMatchesTarget(graphName) : false;
  3294. if (hasRepo && hasGraph) {
  3295. return repoMatches && graphMatches;
  3296. }
  3297. if (hasRepo) return repoMatches;
  3298. if (hasGraph) return graphMatches;
  3299. return false;
  3300. };
  3301. const listGraphCards = () =>
  3302. Array.from(document.querySelectorAll('div[data-testid^="logseq_db_"]'));
  3303. const findGraphCard = () => {
  3304. const exact = document.querySelector('div[data-testid="logseq_db_' + config.graphName + '"]');
  3305. if (exact) return exact;
  3306. const byTestId = listGraphCards()
  3307. .find((card) => lower(card.getAttribute('data-testid')).includes(targetGraphLower));
  3308. if (byTestId) return byTestId;
  3309. return listGraphCards()
  3310. .find((card) => lower(card.textContent).includes(targetGraphLower));
  3311. };
  3312. const clickRefresh = () => {
  3313. const candidates = Array.from(document.querySelectorAll('button,span,a'));
  3314. const refreshNode = candidates.find((el) => (el.textContent || '').trim() === 'Refresh');
  3315. const clickable = refreshNode ? (refreshNode.closest('button') || refreshNode) : null;
  3316. return dispatchClick(clickable);
  3317. };
  3318. const clickGraphCard = (card) => {
  3319. if (!card) return false;
  3320. const anchors = Array.from(card.querySelectorAll('a'));
  3321. const exactAnchor = anchors.find((el) => lower(el.textContent).trim() === targetGraphLower);
  3322. const looseAnchor = anchors.find((el) => lower(el.textContent).includes(targetGraphLower));
  3323. const anyAnchor = anchors[0];
  3324. const actionButton = Array.from(card.querySelectorAll('button'))
  3325. .find((el) => lower(el.textContent).includes(targetGraphLower));
  3326. const target = exactAnchor || looseAnchor || anyAnchor || actionButton || card;
  3327. return dispatchClick(target);
  3328. };
  3329. const getCurrentGraphName = async () => {
  3330. try {
  3331. if (!globalThis.logseq?.api?.get_current_graph) return null;
  3332. const current = await logseq.api.get_current_graph();
  3333. if (!current || typeof current !== 'object') return null;
  3334. if (typeof current.name === 'string' && current.name.length > 0) return current.name;
  3335. if (typeof current.url === 'string' && current.url.length > 0) {
  3336. const parts = current.url.split('/').filter(Boolean);
  3337. return parts[parts.length - 1] || null;
  3338. }
  3339. } catch (_error) {
  3340. // ignore
  3341. }
  3342. return null;
  3343. };
  3344. const getCurrentRepoName = () => {
  3345. try {
  3346. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  3347. const value = logseq.api.get_state_from_store(['git/current-repo']);
  3348. return typeof value === 'string' && value.length > 0 ? value : null;
  3349. } catch (_error) {
  3350. return null;
  3351. }
  3352. };
  3353. const getDownloadingGraphUuid = () => {
  3354. try {
  3355. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  3356. return logseq.api.get_state_from_store(['rtc/downloading-graph-uuid']);
  3357. } catch (_error) {
  3358. return null;
  3359. }
  3360. };
  3361. const getRtcLog = () => {
  3362. try {
  3363. if (!globalThis.logseq?.api?.get_state_from_store) return null;
  3364. return logseq.api.get_state_from_store(['rtc/log']);
  3365. } catch (_error) {
  3366. return null;
  3367. }
  3368. };
  3369. const asLower = (value) => String(value || '').toLowerCase();
  3370. const parseRtcDownloadLog = (value) => {
  3371. if (!value || typeof value !== 'object') return null;
  3372. const type = value.type || value['type'] || null;
  3373. const typeLower = asLower(type);
  3374. if (!typeLower.includes('rtc.log/download')) return null;
  3375. const subType =
  3376. value['sub-type'] ||
  3377. value.subType ||
  3378. value.subtype ||
  3379. value.sub_type ||
  3380. null;
  3381. const graphUuid =
  3382. value['graph-uuid'] ||
  3383. value.graphUuid ||
  3384. value.graph_uuid ||
  3385. null;
  3386. const message = value.message || null;
  3387. return {
  3388. type: String(type || ''),
  3389. subType: String(subType || ''),
  3390. graphUuid: graphUuid ? String(graphUuid) : null,
  3391. message: message ? String(message) : null,
  3392. };
  3393. };
  3394. const probeGraphReady = async () => {
  3395. try {
  3396. if (!globalThis.logseq?.api?.get_current_page_blocks_tree) {
  3397. return { ok: false, reason: 'get_current_page_blocks_tree unavailable' };
  3398. }
  3399. await logseq.api.get_current_page_blocks_tree();
  3400. return { ok: true, reason: null };
  3401. } catch (error) {
  3402. return { ok: false, reason: String(error?.message || error) };
  3403. }
  3404. };
  3405. const initialGraphName = await getCurrentGraphName();
  3406. const initialRepoName = getCurrentRepoName();
  3407. const initialTargetMatched = stateMatchesTarget(initialRepoName, initialGraphName);
  3408. if (!state.initialGraphName && initialGraphName) {
  3409. state.initialGraphName = initialGraphName;
  3410. }
  3411. if (!state.initialRepoName && initialRepoName) {
  3412. state.initialRepoName = initialRepoName;
  3413. }
  3414. if (state.initialTargetMatched === null) {
  3415. state.initialTargetMatched = initialTargetMatched;
  3416. }
  3417. const shouldForceSelection =
  3418. (config.forceSelection === true && !state.graphCardClicked && !state.downloadStarted) ||
  3419. !initialTargetMatched;
  3420. let onGraphsPage = location.hash.includes('/graphs');
  3421. if ((shouldForceSelection || !initialTargetMatched) && !onGraphsPage) {
  3422. try {
  3423. location.hash = '#/graphs';
  3424. state.gotoGraphsOk = true;
  3425. } catch (error) {
  3426. state.gotoGraphsError = String(error?.message || error);
  3427. }
  3428. onGraphsPage = location.hash.includes('/graphs');
  3429. }
  3430. const modal = document.querySelector('.e2ee-password-modal-content');
  3431. const passwordModalVisible = !!modal;
  3432. let passwordAttempted = false;
  3433. let passwordSubmittedThisStep = false;
  3434. if (modal) {
  3435. const passwordInputs = Array.from(
  3436. modal.querySelectorAll('input[type="password"], .ls-toggle-password-input input, input')
  3437. );
  3438. if (passwordInputs.length >= 2) {
  3439. setInputValue(passwordInputs[0], config.password);
  3440. setInputValue(passwordInputs[1], config.password);
  3441. passwordAttempted = true;
  3442. } else if (passwordInputs.length === 1) {
  3443. setInputValue(passwordInputs[0], config.password);
  3444. passwordAttempted = true;
  3445. }
  3446. if (passwordAttempted) {
  3447. state.passwordAttempts += 1;
  3448. }
  3449. const submitButton = Array.from(modal.querySelectorAll('button'))
  3450. .find((button) => /(submit|open|unlock|confirm|enter)/i.test((button.textContent || '').trim()));
  3451. if (submitButton && !submitButton.disabled) {
  3452. passwordSubmittedThisStep = dispatchClick(submitButton);
  3453. state.passwordSubmitted = state.passwordSubmitted || passwordSubmittedThisStep;
  3454. state.actionTriggered = state.actionTriggered || passwordSubmittedThisStep;
  3455. }
  3456. }
  3457. let graphCardClickedThisStep = false;
  3458. let refreshClickedThisStep = false;
  3459. if (location.hash.includes('/graphs')) {
  3460. const card = findGraphCard();
  3461. if (card) {
  3462. const now = Date.now();
  3463. state.graphDetected = true;
  3464. if (!state.graphCardClicked && now - state.lastGraphClickAt >= 500) {
  3465. graphCardClickedThisStep = clickGraphCard(card);
  3466. if (graphCardClickedThisStep) {
  3467. state.lastGraphClickAt = now;
  3468. state.switchAttempts += 1;
  3469. }
  3470. state.graphCardClicked = state.graphCardClicked || graphCardClickedThisStep;
  3471. state.actionTriggered = state.actionTriggered || graphCardClickedThisStep;
  3472. }
  3473. } else {
  3474. const now = Date.now();
  3475. if (now - state.lastRefreshAt >= 2000) {
  3476. refreshClickedThisStep = clickRefresh();
  3477. if (refreshClickedThisStep) {
  3478. state.refreshCount += 1;
  3479. state.lastRefreshAt = now;
  3480. }
  3481. }
  3482. }
  3483. }
  3484. const downloadingGraphUuid = getDownloadingGraphUuid();
  3485. if (downloadingGraphUuid) {
  3486. state.actionTriggered = true;
  3487. state.downloadStarted = true;
  3488. }
  3489. const rtcDownloadLog = parseRtcDownloadLog(getRtcLog());
  3490. if (rtcDownloadLog) {
  3491. state.lastDownloadLog = rtcDownloadLog;
  3492. const subTypeLower = asLower(rtcDownloadLog.subType);
  3493. const messageLower = asLower(rtcDownloadLog.message);
  3494. if (subTypeLower.includes('download-progress') || subTypeLower.includes('downloadprogress')) {
  3495. state.downloadStarted = true;
  3496. }
  3497. if (
  3498. (subTypeLower.includes('download-completed') || subTypeLower.includes('downloadcompleted')) &&
  3499. messageLower.includes('ready')
  3500. ) {
  3501. state.downloadStarted = true;
  3502. state.downloadCompleted = true;
  3503. state.downloadCompletionSource = 'rtc-log';
  3504. }
  3505. }
  3506. const currentGraphName = await getCurrentGraphName();
  3507. const currentRepoName = getCurrentRepoName();
  3508. const onGraphsPageFinal = location.hash.includes('/graphs');
  3509. const repoMatchesTarget = graphNameMatchesTarget(currentRepoName);
  3510. const graphMatchesTarget = graphNameMatchesTarget(currentGraphName);
  3511. const switchedToTargetGraph = stateMatchesTarget(currentRepoName, currentGraphName) && !onGraphsPageFinal;
  3512. if (switchedToTargetGraph) {
  3513. state.targetStateStableHits += 1;
  3514. } else {
  3515. state.targetStateStableHits = 0;
  3516. }
  3517. if (
  3518. !switchedToTargetGraph &&
  3519. !onGraphsPageFinal &&
  3520. !passwordModalVisible &&
  3521. !state.downloadStarted &&
  3522. !state.graphCardClicked
  3523. ) {
  3524. try {
  3525. location.hash = '#/graphs';
  3526. state.gotoGraphsOk = true;
  3527. } catch (error) {
  3528. state.gotoGraphsError = String(error?.message || error);
  3529. }
  3530. }
  3531. const needsReadinessProbe =
  3532. switchedToTargetGraph &&
  3533. !passwordModalVisible &&
  3534. !downloadingGraphUuid;
  3535. const readyProbe = needsReadinessProbe
  3536. ? await probeGraphReady()
  3537. : { ok: false, reason: 'skipped' };
  3538. if (state.downloadStarted && !state.downloadCompleted && readyProbe.ok) {
  3539. state.downloadCompleted = true;
  3540. state.downloadCompletionSource = 'db-ready-probe';
  3541. }
  3542. const downloadLifecycleSatisfied = !state.downloadStarted || state.downloadCompleted;
  3543. const requiresAction = config.requireAction !== false;
  3544. const ok =
  3545. switchedToTargetGraph &&
  3546. !passwordModalVisible &&
  3547. !downloadingGraphUuid &&
  3548. readyProbe.ok &&
  3549. downloadLifecycleSatisfied &&
  3550. (!requiresAction || state.actionTriggered) &&
  3551. state.targetStateStableHits >= 2;
  3552. const availableCards = listGraphCards().slice(0, 10).map((card) => ({
  3553. dataTestId: card.getAttribute('data-testid'),
  3554. text: (card.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
  3555. }));
  3556. return {
  3557. ok,
  3558. targetGraph: config.graphName,
  3559. initialGraphName: state.initialGraphName || null,
  3560. initialRepoName: state.initialRepoName || null,
  3561. initialTargetMatched: state.initialTargetMatched,
  3562. currentGraphName,
  3563. currentRepoName,
  3564. gotoGraphsOk: state.gotoGraphsOk,
  3565. gotoGraphsError: state.gotoGraphsError,
  3566. onGraphsPage: onGraphsPageFinal,
  3567. downloadingGraphUuid,
  3568. switchedToTargetGraph,
  3569. repoMatchesTarget,
  3570. graphMatchesTarget,
  3571. readyProbe,
  3572. actionTriggered: state.actionTriggered,
  3573. graphDetected: state.graphDetected,
  3574. graphCardClicked: state.graphCardClicked,
  3575. graphCardClickedThisStep,
  3576. switchAttempts: state.switchAttempts,
  3577. refreshCount: state.refreshCount,
  3578. refreshClickedThisStep,
  3579. passwordAttempts: state.passwordAttempts,
  3580. passwordAttempted,
  3581. passwordModalVisible,
  3582. passwordSubmitted: state.passwordSubmitted,
  3583. passwordSubmittedThisStep,
  3584. downloadStarted: state.downloadStarted,
  3585. downloadCompleted: state.downloadCompleted,
  3586. downloadCompletionSource: state.downloadCompletionSource,
  3587. targetStateStableHits: state.targetStateStableHits,
  3588. lastDownloadLog: state.lastDownloadLog,
  3589. availableCards,
  3590. };
  3591. })())()`;
  3592. }
  3593. async function runGraphBootstrap(sessionName, args, runOptions) {
  3594. const deadline = Date.now() + args.switchTimeoutMs;
  3595. const bootstrapRunId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
  3596. let lastBootstrap = null;
  3597. while (Date.now() < deadline) {
  3598. const bootstrapProgram = buildGraphBootstrapProgram({
  3599. runId: bootstrapRunId,
  3600. graphName: args.graph,
  3601. password: args.e2ePassword,
  3602. forceSelection: true,
  3603. requireAction: true,
  3604. });
  3605. const bootstrapEvaluation = await runAgentBrowser(
  3606. sessionName,
  3607. ['eval', '--stdin'],
  3608. {
  3609. input: bootstrapProgram,
  3610. timeoutMs: BOOTSTRAP_EVAL_TIMEOUT_MS,
  3611. ...runOptions,
  3612. }
  3613. );
  3614. const bootstrap = bootstrapEvaluation?.data?.result;
  3615. if (!bootstrap || typeof bootstrap !== 'object') {
  3616. throw new Error('Graph bootstrap returned empty state for session ' + sessionName);
  3617. }
  3618. lastBootstrap = bootstrap;
  3619. if (bootstrap.ok) {
  3620. return bootstrap;
  3621. }
  3622. await sleep(250);
  3623. }
  3624. throw new Error(
  3625. 'Failed to switch/download graph "' + args.graph + '" within timeout. ' +
  3626. 'Last bootstrap state: ' + JSON.stringify(lastBootstrap)
  3627. );
  3628. }
  3629. function buildGraphProbeProgram(graphName) {
  3630. return `(() => (async () => {
  3631. const target = ${JSON.stringify(String(graphName || ''))}.toLowerCase();
  3632. const lower = (v) => String(v || '').toLowerCase();
  3633. const matches = (value) => {
  3634. const v = lower(value);
  3635. if (!v) return false;
  3636. return v === target || v.endsWith('/' + target) || v.endsWith('_' + target) || v.includes('logseq_db_' + target);
  3637. };
  3638. let currentGraphName = null;
  3639. let currentRepoName = null;
  3640. try {
  3641. if (globalThis.logseq?.api?.get_current_graph) {
  3642. const current = await logseq.api.get_current_graph();
  3643. currentGraphName = current?.name || current?.url || null;
  3644. }
  3645. } catch (_error) {
  3646. // ignore
  3647. }
  3648. try {
  3649. if (globalThis.logseq?.api?.get_state_from_store) {
  3650. currentRepoName = logseq.api.get_state_from_store(['git/current-repo']) || null;
  3651. }
  3652. } catch (_error) {
  3653. // ignore
  3654. }
  3655. const repoMatchesTarget = matches(currentRepoName);
  3656. const graphMatchesTarget = matches(currentGraphName);
  3657. const onGraphsPage = location.hash.includes('/graphs');
  3658. const stableTarget = (repoMatchesTarget || graphMatchesTarget) && !onGraphsPage;
  3659. return {
  3660. targetGraph: ${JSON.stringify(String(graphName || ''))},
  3661. currentGraphName,
  3662. currentRepoName,
  3663. repoMatchesTarget,
  3664. graphMatchesTarget,
  3665. onGraphsPage,
  3666. stableTarget,
  3667. };
  3668. })())()`;
  3669. }
  3670. async function ensureTargetGraphBeforeOps(sessionName, args, runOptions) {
  3671. let lastProbe = null;
  3672. let lastBootstrap = null;
  3673. for (let attempt = 0; attempt < 4; attempt += 1) {
  3674. const probeEval = await runAgentBrowser(
  3675. sessionName,
  3676. ['eval', '--stdin'],
  3677. {
  3678. input: buildGraphProbeProgram(args.graph),
  3679. ...runOptions,
  3680. }
  3681. );
  3682. const probe = probeEval?.data?.result;
  3683. lastProbe = probe;
  3684. if (probe?.stableTarget) {
  3685. return { ok: true, probe, bootstrap: lastBootstrap };
  3686. }
  3687. lastBootstrap = await runGraphBootstrap(sessionName, args, runOptions);
  3688. }
  3689. throw new Error(
  3690. 'Target graph verification failed before ops. ' +
  3691. 'Last probe: ' + JSON.stringify(lastProbe) + '. ' +
  3692. 'Last bootstrap: ' + JSON.stringify(lastBootstrap)
  3693. );
  3694. }
  3695. function buildSessionNames(baseSession, instances) {
  3696. if (instances <= 1) return [baseSession];
  3697. const sessions = [];
  3698. for (let i = 0; i < instances; i += 1) {
  3699. sessions.push(`${baseSession}-${i + 1}`);
  3700. }
  3701. return sessions;
  3702. }
  3703. function buildSimulationOperationPlan(totalOps, profile) {
  3704. if (!Number.isInteger(totalOps) || totalOps <= 0) {
  3705. throw new Error('totalOps must be a positive integer');
  3706. }
  3707. if (profile !== 'fast' && profile !== 'full') {
  3708. throw new Error('profile must be one of: fast, full');
  3709. }
  3710. const operationOrder = profile === 'full'
  3711. ? FULL_PROFILE_OPERATION_ORDER
  3712. : FAST_PROFILE_OPERATION_ORDER;
  3713. const plan = [];
  3714. for (let i = 0; i < totalOps; i += 1) {
  3715. plan.push(operationOrder[i % operationOrder.length]);
  3716. }
  3717. return plan;
  3718. }
  3719. function shuffleOperationPlan(plan, rng = Math.random) {
  3720. const shuffled = Array.isArray(plan) ? [...plan] : [];
  3721. for (let i = shuffled.length - 1; i > 0; i -= 1) {
  3722. const j = Math.floor(rng() * (i + 1));
  3723. const tmp = shuffled[i];
  3724. shuffled[i] = shuffled[j];
  3725. shuffled[j] = tmp;
  3726. }
  3727. return shuffled;
  3728. }
  3729. function computeRendererEvalTimeoutMs(syncSettleTimeoutMs, opCount) {
  3730. return Math.max(
  3731. 1800000,
  3732. RENDERER_EVAL_BASE_TIMEOUT_MS +
  3733. (syncSettleTimeoutMs * 2) +
  3734. 300000 +
  3735. (opCount * 500) +
  3736. 30000
  3737. );
  3738. }
  3739. function buildReplayCaptureProbeProgram(markerPrefix) {
  3740. return `(() => {
  3741. const key = '__logseqOpReplayCaptureStore';
  3742. const consoleKey = '__logseqOpConsoleCaptureStore';
  3743. const wsKey = '__logseqOpWsCaptureStore';
  3744. const marker = ${JSON.stringify(String(markerPrefix || ''))};
  3745. const store = window[key];
  3746. const consoleStore = window[consoleKey];
  3747. const wsStore = window[wsKey];
  3748. const entry = store && typeof store === 'object' ? store[marker] : null;
  3749. const consoleEntry =
  3750. consoleStore && typeof consoleStore === 'object' ? consoleStore[marker] : null;
  3751. const wsEntry = wsStore && typeof wsStore === 'object' ? wsStore[marker] : null;
  3752. if (!entry && !consoleEntry && !wsEntry) return null;
  3753. return {
  3754. replayCapture: entry && typeof entry === 'object' ? entry : null,
  3755. consoleLogs: Array.isArray(consoleEntry) ? consoleEntry : [],
  3756. wsMessages: wsEntry && typeof wsEntry === 'object' ? wsEntry : null,
  3757. };
  3758. })()`;
  3759. }
  3760. async function collectFailureReplayCapture(sessionName, markerPrefix, runOptions) {
  3761. try {
  3762. const evaluation = await runAgentBrowser(
  3763. sessionName,
  3764. ['eval', '--stdin'],
  3765. {
  3766. input: buildReplayCaptureProbeProgram(markerPrefix),
  3767. timeoutMs: 20000,
  3768. ...runOptions,
  3769. }
  3770. );
  3771. const value = evaluation?.data?.result;
  3772. return value && typeof value === 'object' ? value : null;
  3773. } catch (_error) {
  3774. return null;
  3775. }
  3776. }
  3777. function summarizeRounds(rounds) {
  3778. return rounds.reduce(
  3779. (acc, round) => {
  3780. const roundCounts = round?.counts && typeof round.counts === 'object' ? round.counts : {};
  3781. for (const [k, v] of Object.entries(roundCounts)) {
  3782. acc.counts[k] = (acc.counts[k] || 0) + (Number(v) || 0);
  3783. }
  3784. acc.requestedOps += Number(round.requestedOps || 0);
  3785. acc.executedOps += Number(round.executedOps || 0);
  3786. acc.errorCount += Number(round.errorCount || 0);
  3787. if (round.ok !== true) {
  3788. acc.failedRounds.push(round.round);
  3789. }
  3790. return acc;
  3791. },
  3792. { counts: {}, requestedOps: 0, executedOps: 0, errorCount: 0, failedRounds: [] }
  3793. );
  3794. }
  3795. function mergeOutlinerCoverageIntoRound(round) {
  3796. if (!round || typeof round !== 'object') return round;
  3797. const coverage =
  3798. round.outlinerOpCoverage && typeof round.outlinerOpCoverage === 'object'
  3799. ? round.outlinerOpCoverage
  3800. : null;
  3801. if (!coverage) return round;
  3802. const expectedOpsRaw = Array.isArray(coverage.expectedOps) ? coverage.expectedOps : [];
  3803. const expectedOps = expectedOpsRaw
  3804. .map((op) => (typeof op === 'string' ? op.trim() : ''))
  3805. .filter((op) => op.length > 0);
  3806. if (expectedOps.length === 0) return round;
  3807. const baseRequestedPlan = Array.isArray(round.requestedPlan) ? round.requestedPlan : [];
  3808. if (baseRequestedPlan.some((op) => typeof op === 'string' && op.startsWith('outliner:'))) {
  3809. return round;
  3810. }
  3811. const baseOpLog = Array.isArray(round.opLog) ? round.opLog : [];
  3812. const baseCounts =
  3813. round.counts && typeof round.counts === 'object' && !Array.isArray(round.counts)
  3814. ? round.counts
  3815. : {};
  3816. const resultByOp = new Map();
  3817. const coverageResults = Array.isArray(coverage.results)
  3818. ? coverage.results
  3819. : (Array.isArray(coverage.sample) ? coverage.sample : []);
  3820. for (const item of coverageResults) {
  3821. if (!item || typeof item !== 'object') continue;
  3822. if (typeof item.op !== 'string' || item.op.length === 0) continue;
  3823. if (!resultByOp.has(item.op)) resultByOp.set(item.op, item);
  3824. }
  3825. const coverageEntries = expectedOps.map((op, index) => {
  3826. const result = resultByOp.get(op) || null;
  3827. const detail = {
  3828. kind: 'outlinerCoverage',
  3829. op,
  3830. ok: result ? result.ok !== false : true,
  3831. error: result?.error || null,
  3832. durationMs: Number.isFinite(Number(result?.durationMs))
  3833. ? Number(result.durationMs)
  3834. : null,
  3835. detail: result?.detail || null,
  3836. };
  3837. return {
  3838. index,
  3839. requested: `outliner:${op}`,
  3840. executedAs: `outliner:${op}`,
  3841. detail,
  3842. };
  3843. });
  3844. const indexOffset = coverageEntries.length;
  3845. const shiftedBaseOpLog = baseOpLog.map((entry, idx) => {
  3846. const nextEntry = entry && typeof entry === 'object' ? { ...entry } : {};
  3847. const originalIndex = Number(nextEntry.index);
  3848. nextEntry.index = Number.isInteger(originalIndex) ? originalIndex + indexOffset : indexOffset + idx;
  3849. return nextEntry;
  3850. });
  3851. const requestedPlan = [
  3852. ...expectedOps.map((op) => `outliner:${op}`),
  3853. ...baseRequestedPlan,
  3854. ];
  3855. const opLog = [...coverageEntries, ...shiftedBaseOpLog];
  3856. const executedCoverageCount = coverageEntries.filter((entry) => entry?.detail?.ok !== false).length;
  3857. const baseExecutedOps = Number.isFinite(Number(round.executedOps))
  3858. ? Number(round.executedOps)
  3859. : shiftedBaseOpLog.length;
  3860. const counts = {
  3861. ...baseCounts,
  3862. outlinerCoverage: expectedOps.length,
  3863. outlinerCoverageFailed: Array.isArray(coverage.failedOps)
  3864. ? coverage.failedOps.length
  3865. : 0,
  3866. };
  3867. return {
  3868. ...round,
  3869. requestedOps: requestedPlan.length,
  3870. executedOps: baseExecutedOps + executedCoverageCount,
  3871. requestedPlan,
  3872. opLog,
  3873. opLogSample: opLog.slice(0, 20),
  3874. counts,
  3875. };
  3876. }
  3877. async function runSimulationForSession(sessionName, index, args, sharedConfig) {
  3878. if (args.resetSession) {
  3879. try {
  3880. await runAgentBrowser(sessionName, ['close'], {
  3881. autoConnect: false,
  3882. headed: false,
  3883. });
  3884. } catch (_error) {
  3885. // session may not exist yet
  3886. }
  3887. }
  3888. const runOptions = {
  3889. headed: args.headed,
  3890. autoConnect: args.autoConnect,
  3891. profile: sharedConfig.instanceProfiles[index] ?? null,
  3892. launchArgs: sharedConfig.effectiveLaunchArgs,
  3893. executablePath: sharedConfig.effectiveExecutablePath,
  3894. };
  3895. await runAgentBrowser(sessionName, ['open', args.url], runOptions);
  3896. await ensureActiveTabOnTargetUrl(sessionName, args.url, runOptions);
  3897. const rounds = [];
  3898. let bootstrap = null;
  3899. const fixedPlanForInstance =
  3900. sharedConfig.fixedPlansByInstance instanceof Map
  3901. ? sharedConfig.fixedPlansByInstance.get(index + 1)
  3902. : null;
  3903. const rendererEvalTimeoutMs = computeRendererEvalTimeoutMs(
  3904. args.syncSettleTimeoutMs,
  3905. Array.isArray(fixedPlanForInstance) && fixedPlanForInstance.length > 0
  3906. ? fixedPlanForInstance.length
  3907. : sharedConfig.plan.length
  3908. );
  3909. for (let round = 0; round < args.rounds; round += 1) {
  3910. const roundSeed = deriveSeed(
  3911. sharedConfig.seed ?? sharedConfig.runId,
  3912. sessionName,
  3913. index + 1,
  3914. round + 1
  3915. );
  3916. const roundRng = createSeededRng(roundSeed);
  3917. bootstrap = await runGraphBootstrap(sessionName, args, runOptions);
  3918. const clientPlan =
  3919. Array.isArray(fixedPlanForInstance) && fixedPlanForInstance.length > 0
  3920. ? [...fixedPlanForInstance]
  3921. : shuffleOperationPlan(sharedConfig.plan, roundRng);
  3922. const markerPrefix = `${sharedConfig.runPrefix}r${round + 1}-client-${index + 1}-`;
  3923. const rendererProgram = buildRendererProgram({
  3924. runPrefix: sharedConfig.runPrefix,
  3925. markerPrefix,
  3926. plan: clientPlan,
  3927. seed: roundSeed,
  3928. undoRedoDelayMs: args.undoRedoDelayMs,
  3929. readyTimeoutMs: RENDERER_READY_TIMEOUT_MS,
  3930. readyPollDelayMs: RENDERER_READY_POLL_DELAY_MS,
  3931. syncSettleTimeoutMs: args.syncSettleTimeoutMs,
  3932. opTimeoutMs: args.opTimeoutMs,
  3933. scenario: args.scenario,
  3934. fallbackPageName: FALLBACK_PAGE_NAME,
  3935. verifyChecksum: args.verifyChecksum,
  3936. captureReplay: args.captureReplay,
  3937. });
  3938. try {
  3939. const evaluation = await runAgentBrowser(
  3940. sessionName,
  3941. ['eval', '--stdin'],
  3942. {
  3943. input: rendererProgram,
  3944. timeoutMs: rendererEvalTimeoutMs,
  3945. ...runOptions,
  3946. }
  3947. );
  3948. const value = evaluation?.data?.result;
  3949. if (!value) {
  3950. throw new Error(`Unexpected empty result from agent-browser eval (round ${round + 1})`);
  3951. }
  3952. const normalizedRound = mergeOutlinerCoverageIntoRound(value);
  3953. rounds.push({
  3954. round: round + 1,
  3955. ...normalizedRound,
  3956. });
  3957. } catch (error) {
  3958. const captured = await collectFailureReplayCapture(sessionName, markerPrefix, runOptions);
  3959. if (captured && typeof captured === 'object') {
  3960. const replayCapture =
  3961. captured.replayCapture && typeof captured.replayCapture === 'object'
  3962. ? captured.replayCapture
  3963. : {};
  3964. const fallbackOpLog = Array.isArray(replayCapture.opLog) ? replayCapture.opLog : [];
  3965. const fallbackTxCapture =
  3966. replayCapture.txCapture && typeof replayCapture.txCapture === 'object'
  3967. ? replayCapture.txCapture
  3968. : null;
  3969. const fallbackInitialDb =
  3970. replayCapture.initialDb && typeof replayCapture.initialDb === 'object'
  3971. ? replayCapture.initialDb
  3972. : null;
  3973. const fallbackConsoleLogs = Array.isArray(captured.consoleLogs)
  3974. ? captured.consoleLogs
  3975. : [];
  3976. const fallbackWsMessages =
  3977. captured.wsMessages && typeof captured.wsMessages === 'object'
  3978. ? captured.wsMessages
  3979. : null;
  3980. const fallbackExecutedOps = fallbackOpLog.length;
  3981. const roundResult = {
  3982. round: round + 1,
  3983. ok: false,
  3984. requestedOps: clientPlan.length,
  3985. executedOps: fallbackExecutedOps,
  3986. counts: {},
  3987. markerPrefix,
  3988. anchorUuid: null,
  3989. finalManagedCount: 0,
  3990. sampleManaged: [],
  3991. errorCount: 1,
  3992. errors: [
  3993. {
  3994. index: fallbackExecutedOps,
  3995. requested: 'eval',
  3996. attempted: 'eval',
  3997. message: String(error?.message || error),
  3998. },
  3999. ],
  4000. requestedPlan: Array.isArray(clientPlan) ? [...clientPlan] : [],
  4001. opLog: fallbackOpLog,
  4002. opLogSample: fallbackOpLog.slice(0, 20),
  4003. initialDb: fallbackInitialDb,
  4004. txCapture: fallbackTxCapture,
  4005. consoleLogs: fallbackConsoleLogs,
  4006. wsMessages: fallbackWsMessages,
  4007. checksum: null,
  4008. recoveredFromEvalFailure: true,
  4009. };
  4010. rounds.push(roundResult);
  4011. }
  4012. error.partialResult = {
  4013. ok: false,
  4014. rounds: [...rounds],
  4015. ...summarizeRounds(rounds),
  4016. };
  4017. throw error;
  4018. }
  4019. }
  4020. const summary = summarizeRounds(rounds);
  4021. const value = {
  4022. ok: summary.failedRounds.length === 0,
  4023. rounds,
  4024. requestedOps: summary.requestedOps,
  4025. executedOps: summary.executedOps,
  4026. counts: summary.counts,
  4027. errorCount: summary.errorCount,
  4028. failedRounds: summary.failedRounds,
  4029. };
  4030. value.runtime = {
  4031. session: sessionName,
  4032. instanceIndex: index + 1,
  4033. effectiveProfile: runOptions.profile,
  4034. effectiveLaunchArgs: sharedConfig.effectiveLaunchArgs,
  4035. effectiveExecutablePath: sharedConfig.effectiveExecutablePath,
  4036. bootstrap,
  4037. rounds: args.rounds,
  4038. opProfile: args.opProfile,
  4039. scenario: args.scenario,
  4040. opTimeoutMs: args.opTimeoutMs,
  4041. seed: args.seed,
  4042. verifyChecksum: args.verifyChecksum,
  4043. captureReplay: args.captureReplay,
  4044. cleanupTodayPage: args.cleanupTodayPage,
  4045. autoConnect: args.autoConnect,
  4046. headed: args.headed,
  4047. };
  4048. return value;
  4049. }
  4050. async function runPostSimulationCleanup(sessionName, index, args, sharedConfig) {
  4051. if (!args.cleanupTodayPage) return null;
  4052. const runOptions = {
  4053. headed: args.headed,
  4054. autoConnect: args.autoConnect,
  4055. profile: sharedConfig.instanceProfiles[index] ?? null,
  4056. launchArgs: sharedConfig.effectiveLaunchArgs,
  4057. executablePath: sharedConfig.effectiveExecutablePath,
  4058. };
  4059. const cleanupEval = await runAgentBrowser(
  4060. sessionName,
  4061. ['eval', '--stdin'],
  4062. {
  4063. input: buildCleanupTodayPageProgram({
  4064. cleanupTodayPage: args.cleanupTodayPage,
  4065. }),
  4066. timeoutMs: 30000,
  4067. ...runOptions,
  4068. }
  4069. );
  4070. return cleanupEval?.data?.result || null;
  4071. }
  4072. function formatFailureText(reason) {
  4073. return String(reason?.stack || reason?.message || reason);
  4074. }
  4075. function classifySimulationFailure(reason) {
  4076. const text = formatFailureText(reason).toLowerCase();
  4077. if (
  4078. text.includes('checksum mismatch rtc-log detected') ||
  4079. text.includes('db-sync/checksum-mismatch') ||
  4080. text.includes(':rtc.log/checksum-mismatch')
  4081. ) {
  4082. return 'checksum_mismatch';
  4083. }
  4084. if (
  4085. text.includes('tx rejected rtc-log detected') ||
  4086. text.includes('tx-rejected warning detected') ||
  4087. text.includes('db-sync/tx-rejected') ||
  4088. text.includes(':rtc.log/tx-rejected')
  4089. ) {
  4090. return 'tx_rejected';
  4091. }
  4092. if (
  4093. text.includes('missing-entity-id warning detected') ||
  4094. text.includes('nothing found for entity id')
  4095. ) {
  4096. return 'missing_entity_id';
  4097. }
  4098. if (
  4099. text.includes('numeric-entity-id-in-non-transact-op warning detected') ||
  4100. text.includes('non-transact outliner ops contain numeric entity ids')
  4101. ) {
  4102. return 'numeric_entity_id_in_non_transact_op';
  4103. }
  4104. return 'other';
  4105. }
  4106. function buildRejectedResultEntry(sessionName, index, reason, failFastState) {
  4107. const failureType = classifySimulationFailure(reason);
  4108. const error = formatFailureText(reason);
  4109. const partialResult =
  4110. reason && typeof reason === 'object' && reason.partialResult && typeof reason.partialResult === 'object'
  4111. ? reason.partialResult
  4112. : null;
  4113. const peerCancelledByFailFast =
  4114. (failFastState?.reasonType === 'checksum_mismatch' ||
  4115. failFastState?.reasonType === 'tx_rejected' ||
  4116. failFastState?.reasonType === 'missing_entity_id' ||
  4117. failFastState?.reasonType === 'numeric_entity_id_in_non_transact_op') &&
  4118. Number.isInteger(failFastState?.sourceIndex) &&
  4119. failFastState.sourceIndex !== index;
  4120. if (peerCancelledByFailFast) {
  4121. const cancelledReason =
  4122. failFastState.reasonType === 'tx_rejected'
  4123. ? 'cancelled_due_to_peer_tx_rejected'
  4124. : (
  4125. failFastState.reasonType === 'missing_entity_id'
  4126. ? 'cancelled_due_to_peer_missing_entity_id'
  4127. : (
  4128. failFastState.reasonType === 'numeric_entity_id_in_non_transact_op'
  4129. ? 'cancelled_due_to_peer_numeric_entity_id_in_non_transact_op'
  4130. : 'cancelled_due_to_peer_checksum_mismatch'
  4131. )
  4132. );
  4133. return {
  4134. session: sessionName,
  4135. instanceIndex: index + 1,
  4136. ok: false,
  4137. cancelled: true,
  4138. cancelledReason,
  4139. peerInstanceIndex: failFastState.sourceIndex + 1,
  4140. error,
  4141. failureType: 'peer_cancelled',
  4142. result: partialResult,
  4143. };
  4144. }
  4145. return {
  4146. session: sessionName,
  4147. instanceIndex: index + 1,
  4148. ok: false,
  4149. error,
  4150. failureType,
  4151. result: partialResult,
  4152. };
  4153. }
  4154. function extractChecksumMismatchDetailsFromError(errorText) {
  4155. const text = String(errorText || '');
  4156. const marker = 'checksum mismatch rtc-log detected:';
  4157. const markerIndex = text.toLowerCase().indexOf(marker);
  4158. if (markerIndex === -1) return null;
  4159. const afterMarker = text.slice(markerIndex + marker.length);
  4160. const match = afterMarker.match(/\{[\s\S]*?\}/);
  4161. if (!match) return null;
  4162. try {
  4163. const parsed = JSON.parse(match[0]);
  4164. if (!parsed || typeof parsed !== 'object') return null;
  4165. return parsed;
  4166. } catch (_error) {
  4167. return null;
  4168. }
  4169. }
  4170. function extractTxRejectedDetailsFromError(errorText) {
  4171. const text = String(errorText || '');
  4172. const marker = 'tx rejected rtc-log detected:';
  4173. const markerIndex = text.toLowerCase().indexOf(marker);
  4174. if (markerIndex === -1) return null;
  4175. const afterMarker = text.slice(markerIndex + marker.length);
  4176. const match = afterMarker.match(/\{[\s\S]*?\}/);
  4177. if (!match) return null;
  4178. try {
  4179. const parsed = JSON.parse(match[0]);
  4180. if (!parsed || typeof parsed !== 'object') return null;
  4181. return parsed;
  4182. } catch (_error) {
  4183. return null;
  4184. }
  4185. }
  4186. function buildRunArtifact({ output, args, runContext, failFastState }) {
  4187. const safeOutput = output && typeof output === 'object' ? output : {};
  4188. const resultItems = Array.isArray(safeOutput.results) ? safeOutput.results : [];
  4189. const clients = resultItems.map((item) => {
  4190. const errorText = item?.error ? String(item.error) : null;
  4191. const mismatch = errorText ? extractChecksumMismatchDetailsFromError(errorText) : null;
  4192. const txRejected = errorText ? extractTxRejectedDetailsFromError(errorText) : null;
  4193. const rounds = Array.isArray(item?.result?.rounds)
  4194. ? item.result.rounds.map((round) => ({
  4195. round: Number(round?.round || 0),
  4196. requestedOps: Number(round?.requestedOps || 0),
  4197. executedOps: Number(round?.executedOps || 0),
  4198. errorCount: Number(round?.errorCount || 0),
  4199. requestedPlan: Array.isArray(round?.requestedPlan)
  4200. ? round.requestedPlan
  4201. : [],
  4202. opLog: Array.isArray(round?.opLog)
  4203. ? round.opLog
  4204. : [],
  4205. errors: Array.isArray(round?.errors)
  4206. ? round.errors
  4207. : [],
  4208. warnings: Array.isArray(round?.warnings)
  4209. ? round.warnings
  4210. : [],
  4211. scenario: typeof round?.scenario === 'string' ? round.scenario : null,
  4212. scenarioEvents: Array.isArray(round?.scenarioEvents)
  4213. ? round.scenarioEvents
  4214. : [],
  4215. initialDb: round?.initialDb && typeof round.initialDb === 'object'
  4216. ? round.initialDb
  4217. : null,
  4218. txCapture: round?.txCapture && typeof round.txCapture === 'object'
  4219. ? round.txCapture
  4220. : null,
  4221. consoleLogs: Array.isArray(round?.consoleLogs)
  4222. ? round.consoleLogs
  4223. : [],
  4224. wsMessages: round?.wsMessages && typeof round.wsMessages === 'object'
  4225. ? round.wsMessages
  4226. : null,
  4227. outlinerOpCoverage: round?.outlinerOpCoverage &&
  4228. typeof round.outlinerOpCoverage === 'object'
  4229. ? round.outlinerOpCoverage
  4230. : null,
  4231. }))
  4232. : [];
  4233. return {
  4234. session: item?.session || null,
  4235. instanceIndex: Number.isInteger(item?.instanceIndex) ? item.instanceIndex : null,
  4236. ok: Boolean(item?.ok),
  4237. cancelled: item?.cancelled === true,
  4238. cancelledReason: item?.cancelledReason || null,
  4239. failureType: item?.failureType || null,
  4240. error: errorText,
  4241. mismatch,
  4242. txRejected,
  4243. requestedOps: Number(item?.result?.requestedOps || 0),
  4244. executedOps: Number(item?.result?.executedOps || 0),
  4245. errorCount: Number(item?.result?.errorCount || 0),
  4246. failedRounds: Array.isArray(item?.result?.failedRounds) ? item.result.failedRounds : [],
  4247. requestedPlan: Array.isArray(item?.result?.rounds?.[0]?.requestedPlan)
  4248. ? item.result.rounds[0].requestedPlan
  4249. : [],
  4250. opLogTail: Array.isArray(item?.result?.rounds?.[0]?.opLog)
  4251. ? item.result.rounds[0].opLog.slice(-50)
  4252. : [],
  4253. opLogSample: Array.isArray(item?.result?.rounds?.[0]?.opLogSample)
  4254. ? item.result.rounds[0].opLogSample
  4255. : [],
  4256. errors: Array.isArray(item?.result?.rounds?.[0]?.errors)
  4257. ? item.result.rounds[0].errors
  4258. : [],
  4259. rounds,
  4260. };
  4261. });
  4262. return {
  4263. createdAt: new Date().toISOString(),
  4264. runId: runContext?.runId || null,
  4265. runPrefix: runContext?.runPrefix || null,
  4266. args: args || {},
  4267. summary: {
  4268. ok: Boolean(safeOutput.ok),
  4269. instances: Number(safeOutput.instances || clients.length || 0),
  4270. successCount: Number(safeOutput.successCount || 0),
  4271. failureCount: Number(safeOutput.failureCount || 0),
  4272. },
  4273. failFast: {
  4274. triggered: Boolean(failFastState?.triggered),
  4275. sourceIndex: Number.isInteger(failFastState?.sourceIndex)
  4276. ? failFastState.sourceIndex
  4277. : null,
  4278. reasonType: failFastState?.reasonType || null,
  4279. },
  4280. mismatchCount: clients.filter((item) => item.mismatch).length,
  4281. txRejectedCount: clients.filter((item) => item.txRejected).length,
  4282. clients,
  4283. };
  4284. }
  4285. function extractReplayContext(artifact) {
  4286. const argsOverride =
  4287. artifact && typeof artifact.args === 'object' && artifact.args
  4288. ? { ...artifact.args }
  4289. : {};
  4290. const fixedPlansByInstance = new Map();
  4291. const clients = Array.isArray(artifact?.clients) ? artifact.clients : [];
  4292. for (const client of clients) {
  4293. const instanceIndex = Number(client?.instanceIndex);
  4294. if (!Number.isInteger(instanceIndex) || instanceIndex <= 0) continue;
  4295. if (!Array.isArray(client?.requestedPlan)) continue;
  4296. fixedPlansByInstance.set(instanceIndex, [...client.requestedPlan]);
  4297. }
  4298. return {
  4299. argsOverride,
  4300. fixedPlansByInstance,
  4301. };
  4302. }
  4303. async function writeRunArtifact(artifact, baseDir = DEFAULT_ARTIFACT_BASE_DIR) {
  4304. const runId = String(artifact?.runId || Date.now());
  4305. const artifactDir = path.join(baseDir, runId);
  4306. await fsPromises.mkdir(artifactDir, { recursive: true });
  4307. await fsPromises.writeFile(
  4308. path.join(artifactDir, 'artifact.json'),
  4309. JSON.stringify(artifact, null, 2),
  4310. 'utf8'
  4311. );
  4312. const clients = Array.isArray(artifact?.clients) ? artifact.clients : [];
  4313. for (let i = 0; i < clients.length; i += 1) {
  4314. const client = clients[i];
  4315. const clientIndex =
  4316. Number.isInteger(client?.instanceIndex) && client.instanceIndex > 0
  4317. ? client.instanceIndex
  4318. : i + 1;
  4319. const clientDir = path.join(artifactDir, 'clients', `client-${clientIndex}`);
  4320. await fsPromises.mkdir(clientDir, { recursive: true });
  4321. const rounds = Array.isArray(client?.rounds) ? client.rounds : [];
  4322. for (let j = 0; j < rounds.length; j += 1) {
  4323. const round = rounds[j];
  4324. const roundIndex =
  4325. Number.isInteger(round?.round) && round.round > 0
  4326. ? round.round
  4327. : j + 1;
  4328. const roundPrefix = `round-${roundIndex}`;
  4329. await fsPromises.writeFile(
  4330. path.join(clientDir, `${roundPrefix}-client-ops.json`),
  4331. JSON.stringify(
  4332. {
  4333. requestedPlan: Array.isArray(round?.requestedPlan) ? round.requestedPlan : [],
  4334. opLog: Array.isArray(round?.opLog) ? round.opLog : [],
  4335. outlinerOpCoverage:
  4336. round?.outlinerOpCoverage && typeof round.outlinerOpCoverage === 'object'
  4337. ? round.outlinerOpCoverage
  4338. : null,
  4339. txCapture: round?.txCapture && typeof round.txCapture === 'object'
  4340. ? round.txCapture
  4341. : null,
  4342. errors: Array.isArray(round?.errors) ? round.errors : [],
  4343. warnings: Array.isArray(round?.warnings) ? round.warnings : [],
  4344. scenario: typeof round?.scenario === 'string' ? round.scenario : null,
  4345. scenarioEvents: Array.isArray(round?.scenarioEvents) ? round.scenarioEvents : [],
  4346. },
  4347. null,
  4348. 2
  4349. ),
  4350. 'utf8'
  4351. );
  4352. await fsPromises.writeFile(
  4353. path.join(clientDir, `${roundPrefix}-console-log.json`),
  4354. JSON.stringify(
  4355. Array.isArray(round?.consoleLogs) ? round.consoleLogs : [],
  4356. null,
  4357. 2
  4358. ),
  4359. 'utf8'
  4360. );
  4361. await fsPromises.writeFile(
  4362. path.join(clientDir, `${roundPrefix}-ws-messages.json`),
  4363. JSON.stringify(
  4364. round?.wsMessages && typeof round.wsMessages === 'object'
  4365. ? round.wsMessages
  4366. : { installed: false, outbound: [], inbound: [] },
  4367. null,
  4368. 2
  4369. ),
  4370. 'utf8'
  4371. );
  4372. }
  4373. }
  4374. return artifactDir;
  4375. }
  4376. async function main() {
  4377. let args;
  4378. try {
  4379. args = parseArgs(process.argv.slice(2));
  4380. } catch (error) {
  4381. console.error(error.message);
  4382. console.error('\n' + usage());
  4383. process.exit(1);
  4384. return;
  4385. }
  4386. if (args.help) {
  4387. console.log(usage());
  4388. return;
  4389. }
  4390. let replayContext = {
  4391. sourceArtifactPath: null,
  4392. fixedPlansByInstance: new Map(),
  4393. };
  4394. if (args.replay) {
  4395. const replayPath = path.resolve(args.replay);
  4396. const replayContent = await fsPromises.readFile(replayPath, 'utf8');
  4397. const replayArtifact = JSON.parse(replayContent);
  4398. const extractedReplay = extractReplayContext(replayArtifact);
  4399. args = {
  4400. ...args,
  4401. ...extractedReplay.argsOverride,
  4402. replay: args.replay,
  4403. };
  4404. replayContext = {
  4405. sourceArtifactPath: replayPath,
  4406. fixedPlansByInstance: extractedReplay.fixedPlansByInstance,
  4407. };
  4408. }
  4409. const preview = {
  4410. url: args.url,
  4411. session: args.session,
  4412. instances: args.instances,
  4413. graph: args.graph,
  4414. e2ePassword: args.e2ePassword,
  4415. switchTimeoutMs: args.switchTimeoutMs,
  4416. profile: args.profile,
  4417. executablePath: args.executablePath,
  4418. autoConnect: args.autoConnect,
  4419. resetSession: args.resetSession,
  4420. ops: args.ops,
  4421. opProfile: args.opProfile,
  4422. scenario: args.scenario,
  4423. opTimeoutMs: args.opTimeoutMs,
  4424. seed: args.seed,
  4425. replay: args.replay,
  4426. rounds: args.rounds,
  4427. undoRedoDelayMs: args.undoRedoDelayMs,
  4428. syncSettleTimeoutMs: args.syncSettleTimeoutMs,
  4429. verifyChecksum: args.verifyChecksum,
  4430. captureReplay: args.captureReplay,
  4431. cleanupTodayPage: args.cleanupTodayPage,
  4432. headed: args.headed,
  4433. };
  4434. if (args.printOnly) {
  4435. console.log(JSON.stringify(preview, null, 2));
  4436. return;
  4437. }
  4438. await spawnAndCapture('agent-browser', ['--version']);
  4439. const sessionNames = buildSessionNames(args.session, args.instances);
  4440. let effectiveProfile;
  4441. if (args.profile === 'none') {
  4442. effectiveProfile = null;
  4443. } else if (args.profile === 'auto') {
  4444. const autoName = await detectChromeProfile();
  4445. effectiveProfile = await resolveProfileArgument(autoName);
  4446. } else {
  4447. effectiveProfile = await resolveProfileArgument(args.profile);
  4448. }
  4449. const effectiveExecutablePath =
  4450. args.executablePath || (await detectChromeExecutablePath());
  4451. const effectiveLaunchArgs = effectiveProfile ? buildChromeLaunchArgs(args.url) : null;
  4452. const instanceProfiles = [];
  4453. if (args.instances <= 1 || !effectiveProfile) {
  4454. for (let i = 0; i < args.instances; i += 1) {
  4455. instanceProfiles.push(effectiveProfile);
  4456. }
  4457. } else if (looksLikePath(effectiveProfile)) {
  4458. for (let i = 0; i < args.instances; i += 1) {
  4459. instanceProfiles.push(effectiveProfile);
  4460. }
  4461. } else {
  4462. for (let i = 0; i < args.instances; i += 1) {
  4463. const isolated = await createIsolatedChromeUserDataDir(effectiveProfile, i + 1);
  4464. instanceProfiles.push(isolated);
  4465. }
  4466. }
  4467. const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
  4468. const sharedConfig = {
  4469. runId,
  4470. runPrefix: `op-sim-${runId}-`,
  4471. seed: args.seed,
  4472. replaySource: replayContext.sourceArtifactPath,
  4473. fixedPlansByInstance: replayContext.fixedPlansByInstance,
  4474. captureReplay: args.captureReplay,
  4475. effectiveProfile,
  4476. instanceProfiles,
  4477. effectiveLaunchArgs,
  4478. effectiveExecutablePath,
  4479. plan: buildSimulationOperationPlan(
  4480. Math.max(1, Math.ceil(args.ops / args.instances)),
  4481. args.opProfile
  4482. ),
  4483. };
  4484. const failFastState = {
  4485. triggered: false,
  4486. sourceIndex: null,
  4487. reasonType: null,
  4488. };
  4489. const closeOtherSessions = async (excludeIndex) => {
  4490. await Promise.all(
  4491. sessionNames.map((sessionName, index) => {
  4492. if (index === excludeIndex) return Promise.resolve();
  4493. return runAgentBrowser(sessionName, ['close'], {
  4494. autoConnect: false,
  4495. headed: false,
  4496. }).catch(() => null);
  4497. })
  4498. );
  4499. };
  4500. const tasks = sessionNames.map((sessionName, index) =>
  4501. (async () => {
  4502. try {
  4503. return await runSimulationForSession(sessionName, index, args, sharedConfig);
  4504. } catch (error) {
  4505. if (!failFastState.triggered) {
  4506. failFastState.triggered = true;
  4507. failFastState.sourceIndex = index;
  4508. failFastState.reasonType = classifySimulationFailure(error);
  4509. await closeOtherSessions(index);
  4510. }
  4511. throw error;
  4512. }
  4513. })()
  4514. );
  4515. const settled = await Promise.allSettled(tasks);
  4516. let cleanupTodayPage = null;
  4517. try {
  4518. cleanupTodayPage = await runPostSimulationCleanup(
  4519. sessionNames[0],
  4520. 0,
  4521. args,
  4522. sharedConfig
  4523. );
  4524. } catch (error) {
  4525. cleanupTodayPage = {
  4526. ok: false,
  4527. reason: String(error?.message || error),
  4528. };
  4529. }
  4530. const expectedOpsForInstance = (instanceIndex) => {
  4531. const fixedPlan =
  4532. sharedConfig.fixedPlansByInstance instanceof Map
  4533. ? sharedConfig.fixedPlansByInstance.get(instanceIndex)
  4534. : null;
  4535. const perRound = Array.isArray(fixedPlan) && fixedPlan.length > 0
  4536. ? fixedPlan.length
  4537. : sharedConfig.plan.length;
  4538. return perRound * args.rounds;
  4539. };
  4540. if (sessionNames.length === 1) {
  4541. const single = settled[0];
  4542. if (single.status === 'rejected') {
  4543. const rejected = buildRejectedResultEntry(
  4544. sessionNames[0],
  4545. 0,
  4546. single.reason,
  4547. failFastState
  4548. );
  4549. const value = {
  4550. ...(rejected.result && typeof rejected.result === 'object'
  4551. ? rejected.result
  4552. : {}),
  4553. ok: false,
  4554. error: rejected.error || formatFailureText(single.reason),
  4555. failureType: rejected.failureType || 'other',
  4556. cleanupTodayPage,
  4557. };
  4558. if (rejected.cancelled) value.cancelled = true;
  4559. if (rejected.cancelledReason) value.cancelledReason = rejected.cancelledReason;
  4560. if (Number.isInteger(rejected.peerInstanceIndex)) {
  4561. value.peerInstanceIndex = rejected.peerInstanceIndex;
  4562. }
  4563. try {
  4564. const singleOutput = {
  4565. ok: false,
  4566. instances: 1,
  4567. successCount: 0,
  4568. failureCount: 1,
  4569. results: [{
  4570. session: sessionNames[0],
  4571. instanceIndex: 1,
  4572. ok: false,
  4573. result: value,
  4574. error: rejected.error,
  4575. failureType: rejected.failureType,
  4576. cancelled: rejected.cancelled,
  4577. cancelledReason: rejected.cancelledReason,
  4578. peerInstanceIndex: rejected.peerInstanceIndex,
  4579. }],
  4580. };
  4581. const artifact = buildRunArtifact({
  4582. output: singleOutput,
  4583. args,
  4584. runContext: sharedConfig,
  4585. failFastState,
  4586. });
  4587. value.artifactDir = await writeRunArtifact(artifact);
  4588. } catch (error) {
  4589. value.artifactError = String(error?.message || error);
  4590. }
  4591. console.log(JSON.stringify(value, null, 2));
  4592. process.exitCode = 2;
  4593. return;
  4594. }
  4595. const value = single.value;
  4596. value.cleanupTodayPage = cleanupTodayPage;
  4597. try {
  4598. const singleOutput = {
  4599. ok: value.ok,
  4600. instances: 1,
  4601. successCount: value.ok ? 1 : 0,
  4602. failureCount: value.ok ? 0 : 1,
  4603. results: [{
  4604. session: sessionNames[0],
  4605. instanceIndex: 1,
  4606. ok: value.ok,
  4607. result: value,
  4608. }],
  4609. };
  4610. const artifact = buildRunArtifact({
  4611. output: singleOutput,
  4612. args,
  4613. runContext: sharedConfig,
  4614. failFastState,
  4615. });
  4616. value.artifactDir = await writeRunArtifact(artifact);
  4617. } catch (error) {
  4618. value.artifactError = String(error?.message || error);
  4619. }
  4620. console.log(JSON.stringify(value, null, 2));
  4621. if (!value.ok || value.executedOps < expectedOpsForInstance(1)) {
  4622. process.exitCode = 2;
  4623. }
  4624. return;
  4625. }
  4626. const results = settled.map((entry, idx) => {
  4627. const sessionName = sessionNames[idx];
  4628. if (entry.status === 'fulfilled') {
  4629. const value = entry.value;
  4630. const passed =
  4631. Boolean(value?.ok) &&
  4632. Number(value?.executedOps || 0) >= expectedOpsForInstance(idx + 1);
  4633. return {
  4634. session: sessionName,
  4635. instanceIndex: idx + 1,
  4636. ok: passed,
  4637. result: {
  4638. ...value,
  4639. cleanupTodayPage: idx === 0 ? cleanupTodayPage : null,
  4640. },
  4641. };
  4642. }
  4643. return buildRejectedResultEntry(sessionName, idx, entry.reason, failFastState);
  4644. });
  4645. const successCount = results.filter((item) => item.ok).length;
  4646. const output = {
  4647. ok: successCount === results.length,
  4648. instances: results.length,
  4649. successCount,
  4650. failureCount: results.length - successCount,
  4651. results,
  4652. };
  4653. try {
  4654. const artifact = buildRunArtifact({
  4655. output,
  4656. args,
  4657. runContext: sharedConfig,
  4658. failFastState,
  4659. });
  4660. output.artifactDir = await writeRunArtifact(artifact);
  4661. } catch (error) {
  4662. output.artifactError = String(error?.message || error);
  4663. }
  4664. console.log(JSON.stringify(output, null, 2));
  4665. if (!output.ok) {
  4666. process.exitCode = 2;
  4667. }
  4668. }
  4669. if (require.main === module) {
  4670. main().catch((error) => {
  4671. console.error(error.stack || String(error));
  4672. process.exit(1);
  4673. });
  4674. }
  4675. module.exports = {
  4676. parseArgs,
  4677. isRetryableAgentBrowserError,
  4678. buildCleanupTodayPageProgram,
  4679. classifySimulationFailure,
  4680. buildRejectedResultEntry,
  4681. extractChecksumMismatchDetailsFromError,
  4682. extractTxRejectedDetailsFromError,
  4683. buildRunArtifact,
  4684. extractReplayContext,
  4685. createSeededRng,
  4686. shuffleOperationPlan,
  4687. buildSimulationOperationPlan,
  4688. mergeOutlinerCoverageIntoRound,
  4689. ALL_OUTLINER_OP_COVERAGE_OPS,
  4690. };