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

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