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

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