Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162import { CDN_URL } from "../../src/js/config.js";import { createPost } from "../shared/factories.js";import { bskyLabeler, notificationService, userProfile } from "./testData.js";import { TEST_PLUGIN_ID, TEST_PLUGIN_MANIFEST, TEST_PLUGIN_RAW_MANIFEST, getTestPluginSource,} from "./testPlugins.js";
export class MockServer { constructor() { this.authorFeeds = new Map(); this.bookmarks = []; this.convos = []; this.convoMessages = new Map(); this.convoMemberLists = new Map(); this.convoDelays = new Map(); this.chatLogs = []; this.joinLinkPreviews = new Map(); this.joinLinkJoinedConvos = new Map(); this.failJoinLinkCodes = new Set(); this.createRecordCounter = 0; this.applyWritesCalls = []; // rkey -> { cid, value }; seed for getRecord, updated by putRecord this.threadgateRecords = new Map(); this.putThreadgateCalls = []; this.postgateRecords = new Map(); this.putPostgateCalls = []; this.drafts = []; this.draftCounter = 0; this.deletedDraftIds = []; this.draftLimitReached = false; this.interactionPayloads = []; this.blobCounter = 0; this.blobUploadDelayMs = 0; this.messageCounter = 0; this.sentMessageRequests = []; this.sendMessageFailure = null; this.createRecordFailures = new Map(); this.convoForMembersError = null; this.chatActorStatus = { chatDisabled: false, canCreateGroups: true, groupMemberLimit: 100, }; this.createGroupError = null; this.createGroupRequests = []; this.leaveConvoError = null; this.typeaheadProfiles = []; this.typeaheadDelayMs = 0; this.externalLinkCards = new Map(); this.featuredGifs = []; this.gifSearchResults = new Map(); this.gifRequests = []; this.feedGenerators = []; this.feeds = new Map(); this.hiddenPostUris = []; this.labelerSubscriptions = []; this.labelerViews = [bskyLabeler]; this.mutedWords = []; this.blockedProfiles = []; this.mutedProfiles = []; this.contentLabelPrefs = []; this.notifications = []; this.notificationCursor = undefined; this.notificationsDelayMs = 0; this.notificationsSeenAt = null; this.pinnedFeedUris = []; this.pinnedListUris = []; this.lists = []; this.starterPacks = []; this.listMembers = new Map(); this.currentUserListItems = []; this.posts = []; this.postLikes = new Map(); this.reportPayloads = []; this.postQuotes = new Map(); this.postReposts = new Map(); this.postThreadOthers = new Map(); this.postThreads = new Map(); this.postThreadDelays = new Map(); this.postDelays = new Map(); this.profileFollowers = new Map(); this.profileFollows = new Map(); this.knownFollowers = new Map(); this.profiles = new Map(); this.savedFeedUris = []; this.actorFeeds = new Map(); this.actorLists = new Map(); this.searchFeedGenerators = []; this.trends = []; this.searchPosts = []; this.searchPostsBySort = { top: [], latest: [] }; this.searchProfiles = []; this.searchRequestCounts = { profiles: 0, top: 0, latest: 0, feeds: 0, typeahead: 0, }; this.timelinePosts = []; this.timelineDelayMs = 0; this.pluginSettings = new Map(); this.installedPlugins = []; // Seeded/captured improSearchHistoryPref state; null means the preference // is absent from getPreferences and no putPreferences has written it. this.searchHistory = null; this.getProfilesDelayMs = 0; this.putPreferencesDelayMs = 0; // Override the source served for the local test plugin's main.js; defaults // to the standard fixture when null. this.localPluginSource = null; this.registryEntries = []; this.liveManifest = null; // README markdown served for plugin repos; set to null to simulate a // plugin with no README (404). this.pluginReadme = "# Remote Themes\n\nA test readme for the plugin."; this.tokenRefreshShouldFail = false; this.notificationServiceUnreachable = false; // Which notification service this device has chosen; null means none. this.notificationServiceDid = null; this.registerPushCalls = []; this.unregisterPushCalls = []; this.registerPushStatus = 200; this.slingshotUnreachable = false; this.pdsEndpoint = "http://localhost:8081"; }
// Make slingshot fail to resolve identities, so the app falls back to // resolving handles and DID docs through standard atproto infrastructure. failSlingshotLookup() { this.slingshotUnreachable = true; }
// Every actor the mocks know about, in the precedence order the identity // endpoints resolve them in. _knownIdentities() { const identities = []; const add = (actor) => { if (actor?.did && actor?.handle) { identities.push({ did: actor.did, handle: actor.handle }); } }; [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ].forEach((post) => add(post.author)); this.feedGenerators.forEach((generator) => add(generator.creator)); this.lists.forEach((list) => add(list.creator)); this.starterPacks.forEach((starterPack) => add(starterPack.creator)); this.profiles.forEach((profile) => add(profile)); // The logged-in user always resolves, even when no mock data mentions // them, mirroring the getProfile fallback. add(userProfile); return identities; }
_findIdentity(handleOrDid) { if (!handleOrDid) return null; return ( this._knownIdentities().find( (identity) => identity.handle === handleOrDid || identity.did === handleOrDid, ) ?? null ); }
// Make subsequent OAuth token refreshes fail with a non-retryable error, // simulating a stale/revoked refresh token. failTokenRefresh() { this.tokenRefreshShouldFail = true; }
// Make the dummy notification service fail to resolve, simulating a DID // that points at nothing conforming. failNotificationServiceLookup() { this.notificationServiceUnreachable = true; }
// Start with a notification service already chosen on this device, as if a // previous launch had left it there. Call before setup(). setNotificationServiceDid(did) { this.notificationServiceDid = did; }
// The notification service no longer accepts this user's registrations, as // if the authorization was revoked server-side. Call before setup(). failRegisterPushWithAuthError() { this.registerPushStatus = 401; }
setSearchHistory({ searches = [], profiles = [] } = {}) { this.searchHistory = { searches, profiles }; }
addAuthorFeedPosts(did, filter, posts) { this.authorFeeds.set(`${did}-${filter}`, posts); }
addBookmarks(bookmarks) { this.bookmarks.push(...bookmarks); }
addActorFeeds(did, feedGenerators) { const existing = this.actorFeeds.get(did) || []; this.actorFeeds.set(did, [...existing, ...feedGenerators]); }
addActorLists(did, lists) { const existing = this.actorLists.get(did) || []; this.actorLists.set(did, [...existing, ...lists]); }
addFeedGenerators(feedGenerators) { this.feedGenerators.push(...feedGenerators); }
addLabelerSubscription(did) { this.labelerSubscriptions.push(did); }
addLabelerViews(views) { this.labelerViews.push(...views); }
addFeedItems(feedUri, posts) { this.feeds.set( feedUri, posts.map((post) => ({ post })), ); }
addLists(lists) { this.lists.push(...lists); }
addStarterPacks(starterPacks) { this.starterPacks.push(...starterPacks); }
addListMembers(listUri, profiles) { this.listMembers.set(listUri, profiles); }
addCurrentUserListItem({ uri, listUri, subjectDid }) { this.currentUserListItems.push({ uri, listUri, subjectDid }); }
addListFeedItems(listUri, posts) { this.feeds.set( listUri, posts.map((post) => ({ post })), ); }
setPinnedFeeds(feedUris) { this.pinnedFeedUris = feedUris; }
setPinnedLists(listUris) { this.pinnedListUris = listUris; }
setSavedFeeds(feedUris) { this.savedFeedUris = feedUris; }
addTimelinePosts(posts) { this.timelinePosts.push(...posts); }
// Delays apply to requests made after the setter is called, so a test can // let the initial load complete instantly and only slow down a reload. setTimelineDelay(delayMs) { this.timelineDelayMs = delayMs; }
setBlobUploadDelay(delayMs) { this.blobUploadDelayMs = delayMs; }
setNotificationsDelay(delayMs) { this.notificationsDelayMs = delayMs; }
setNotificationsSeenAt(seenAt) { this.notificationsSeenAt = seenAt; }
addSearchPosts(posts, { sort } = {}) { this.searchPosts.push(...posts); if (sort) { this.searchPostsBySort[sort].push(...posts); } else { this.searchPostsBySort.top.push(...posts); this.searchPostsBySort.latest.push(...posts); } }
addSearchProfiles(profiles) { this.searchProfiles.push(...profiles); }
addSearchFeedGenerators(feedGenerators) { this.searchFeedGenerators.push(...feedGenerators); }
addTrends(trends) { this.trends.push(...trends); }
addTypeaheadProfiles(profiles) { this.typeaheadProfiles.push(...profiles); }
setConvoForMembersError(errorName) { this.convoForMembersError = errorName; }
setChatActorStatus(overrides) { this.chatActorStatus = { ...this.chatActorStatus, ...overrides }; }
setCreateGroupError(errorName) { this.createGroupError = errorName; }
setExternalLinkCard(url, meta) { this.externalLinkCards.set(url, meta); }
addNotifications(notifications, { cursor } = {}) { this.notifications.push(...notifications); this.notificationCursor = cursor; }
addPosts(posts, { delayMs = 0 } = {}) { this.posts.push(...posts); for (const post of posts) { const preview = post?.embed?.joinLinkPreview; if (preview?.code) { this.joinLinkPreviews.set(preview.code, preview); } if (delayMs > 0) { this.postDelays.set(post.uri, delayMs); } } }
addPostLikes(postUri, likes) { this.postLikes.set(postUri, likes); }
addPostQuotes(postUri, quotes) { this.postQuotes.set(postUri, quotes); }
addPostReposts(postUri, reposts) { this.postReposts.set(postUri, reposts); }
setPostThread(postUri, thread, { delayMs = 0 } = {}) { this.postThreads.set(postUri, thread); if (delayMs > 0) { this.postThreadDelays.set(postUri, delayMs); } }
setPostThreadOther(postUri, threadOther) { this.postThreadOthers.set(postUri, threadOther); }
addProfileFollowers(did, followers) { this.profileFollowers.set(did, followers); }
addProfileFollows(did, follows) { this.profileFollows.set(did, follows); }
addKnownFollowers(did, followers) { this.knownFollowers.set(did, followers); }
addProfile(profile) { this.profiles.set(profile.did, profile); }
addConvos(convos) { for (const convo of convos) { if (!this.convoMessages.has(convo.id)) { this.convoMessages.set(convo.id, []); } } this.convos.push(...convos); }
addConvoMembers(convoId, members) { this.convoMemberLists.set(convoId, members); }
failConvoMembers({ status = 500, error = "InternalServerError", message }) { this.convoMembersFailure = { status, error, message }; }
setConvoDelay(convoId, delayMs) { this.convoDelays.set(convoId, delayMs); }
// Queued logs are returned (and drained) by the next chat.bsky.convo.getLog // poll, simulating events arriving while a conversation is open. addChatLogs(logs) { this.chatLogs.push(...logs); }
addConvoMessages(convoId, messages) { this.convoMessages.set(convoId, messages); for (const message of messages) { const preview = message?.embed?.joinLinkPreview; if (preview?.code) { this.joinLinkPreviews.set(preview.code, preview); } } }
setJoinLinkPreview(code, preview) { this.joinLinkPreviews.set(code, preview); }
failJoinLinkRequest(code) { this.failJoinLinkCodes.add(code); }
failCreateRecord( collection, { status = 500, error = "InternalServerError", message } = {}, ) { this.createRecordFailures.set(collection, { status, error, message }); }
failSendMessage({ status = 400, error = "InvalidRequest", message }) { this.sendMessageFailure = { status, error, message }; }
setJoinLinkJoinedConvo(code, convo) { this.joinLinkJoinedConvos.set(code, convo); }
// Simulate flaky delivery of the app's entry module (e.g. a CDN error // serving /js/app.js): failAppScript aborts the request, delayAppScript // stalls it before letting it through. failAppScript() { this.appScriptFailure = true; }
delayAppScript(delayMs) { this.appScriptDelayMs = delayMs; }
// Abort all subsequent document navigations (reloads, redirects) so the // page stays on the current document. UI states that only exist until a // navigation completes (e.g. an in-progress spinner) stay observable. async blockNavigations(page) { await page.route("**/*", async (route) => { if ( route.request().isNavigationRequest() && route.request().resourceType() === "document" ) { await route.abort("aborted"); return; } await route.fallback(); }); }
// Turns a submitted app.bsky.feed.post record into a hydrated post view and // threads it into the mock state (quote counts, reply threads, author // feeds). Shared by the createRecord and applyWrites routes. _ingestPostRecord(record, uri) { let embed; let quotedPostUri; const recordEmbed = record?.embed;
if (recordEmbed?.$type === "app.bsky.embed.images") { embed = { $type: "app.bsky.embed.images#view", images: recordEmbed.images.map((img) => ({ thumb: "", fullsize: "", alt: img.alt || "", aspectRatio: img.aspectRatio, })), }; } else if (recordEmbed?.$type === "app.bsky.embed.video") { embed = { $type: "app.bsky.embed.video#view", cid: recordEmbed.video.ref.$link, playlist: "", alt: recordEmbed.alt || "", aspectRatio: recordEmbed.aspectRatio, }; } else if (recordEmbed?.$type === "app.bsky.embed.external") { embed = { $type: "app.bsky.embed.external#view", external: { uri: recordEmbed.external.uri, title: recordEmbed.external.title || "", description: recordEmbed.external.description || "", }, }; } else if (recordEmbed?.$type === "app.bsky.embed.record") { const quotedRecordUri = recordEmbed.record.uri; const allQuotePosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const quotedPost = allQuotePosts.find((p) => p.uri === quotedRecordUri); const quotedGenerator = this.feedGenerators.find( (g) => g.uri === quotedRecordUri, ); const quotedList = this.lists.find((l) => l.uri === quotedRecordUri); const quotedStarterPack = this.starterPacks.find( (s) => s.uri === quotedRecordUri, ); if (quotedPost) { quotedPostUri = quotedRecordUri; embed = { $type: "app.bsky.embed.record#view", record: { $type: "app.bsky.embed.record#viewRecord", uri: quotedPost.uri, cid: quotedPost.cid, author: quotedPost.author, value: quotedPost.record, indexedAt: quotedPost.indexedAt, labels: [], embeds: [], }, }; quotedPost.quoteCount = (quotedPost.quoteCount || 0) + 1; } else if (quotedGenerator) { embed = { $type: "app.bsky.embed.record#view", record: { $type: "app.bsky.feed.defs#generatorView", ...quotedGenerator, }, }; } else if (quotedList) { embed = { $type: "app.bsky.embed.record#view", record: { $type: "app.bsky.graph.defs#listView", ...quotedList, }, }; } else if (quotedStarterPack) { embed = { $type: "app.bsky.embed.record#view", record: { $type: "app.bsky.graph.defs#starterPackViewBasic", ...quotedStarterPack, }, }; } }
const post = createPost({ uri, text: record?.text || "", authorHandle: userProfile.handle, authorDisplayName: userProfile.displayName, embed, }); this.posts.push(post);
if (quotedPostUri) { const existingQuotes = this.postQuotes.get(quotedPostUri) || []; existingQuotes.push(post); this.postQuotes.set(quotedPostUri, existingQuotes); }
const isReply = !!record?.reply;
if (isReply) { const parentUri = record.reply.parent.uri; const allReplyPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const parentPost = allReplyPosts.find((p) => p.uri === parentUri); if (parentPost) { parentPost.replyCount = (parentPost.replyCount || 0) + 1; } const thread = this.postThreads.get(parentUri); if (thread) { thread.replies = thread.replies || []; thread.replies.push({ $type: "app.bsky.feed.defs#threadViewPost", post, replies: [], }); } }
const feedKey = `${userProfile.did}-posts_and_author_threads`; const existing = this.authorFeeds.get(feedKey) || []; this.authorFeeds.set(feedKey, [post, ...existing]); if (!isReply) { const noRepliesKey = `${userProfile.did}-posts_no_replies`; const existingNoReplies = this.authorFeeds.get(noRepliesKey) || []; this.authorFeeds.set(noRepliesKey, [post, ...existingNoReplies]); } return post; }
async setup(page, { welcomeModalSeen = true } = {}) { // Mark the welcome modal as already seen so it doesn't open over // logged-out tests; pass welcomeModalSeen: false to test the modal itself. if (welcomeModalSeen) { await page.addInitScript(() => { sessionStorage.setItem("welcome-modal-seen", "true"); }); }
if (this.notificationServiceDid) { await page.addInitScript((did) => { localStorage.setItem("push-notification-service", did); }, this.notificationServiceDid); }
// Stub the external destinations "open in bsky.app" / "translate" links // point at. These open via window.open, and popups are separate pages that // page-level routes don't apply to — so the route has to be on the context // or the popup really loads over the network, and page.waitForEvent("popup") // (which resolves once the popup's initial navigation commits) can outlast // the test timeout. await page .context() .route(/^https:\/\/(bsky\.app|translate\.google\.com)\//, (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><html><body></body></html>", }), );
// Stub gif proxy fetches (gif embeds stream from these CDNs). await page.route( /https:\/\/([tk]\.gifs\.bsky\.app|.*\.klipy\.com)\/.*/, (route) => route.fulfill({ status: 200, contentType: "video/webm", body: "", }), );
// GIF picker service (KLIPY via the bsky proxy) await page.route("https://gifs.bsky.app/klipy/v2/**", (route) => { const url = new URL(route.request().url()); const query = url.searchParams.get("q") ?? ""; const pos = url.searchParams.get("pos") ?? ""; this.gifRequests.push({ path: url.pathname, query, pos, }); const isSearch = url.pathname.endsWith("/search"); const results = isSearch ? (this.gifSearchResults.get(query) ?? []) : this.featuredGifs; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ next: "", results }), }); });
// Stub the YouTube embed player so tests don't hit the network. await page.route("https://www.youtube-nocookie.com/**", (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><html><body></body></html>", }), );
// Stub emoji-picker-element's CDN data fetch so tests don't hit the // network. Returns a tiny fixture with one emoji per group the library // requires (its assertEmojiData rejects an empty list). await page.route("**/emoji-picker-element-data@*/**", (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { etag: '"test-emoji-data"' }, body: JSON.stringify([ { annotation: "party popper", emoji: "🎉", group: 0, order: 1, version: 0.6, shortcodes: ["tada"], tags: ["celebration"], }, ]), }), );
// Plugin fixture routes — serve a self-contained test plugin so plugin // e2e tests don't depend on plugins-local/. await page.route("**/plugins-local/index.json", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([ { id: TEST_PLUGIN_MANIFEST.id, name: TEST_PLUGIN_MANIFEST.name, author: TEST_PLUGIN_MANIFEST.author, description: TEST_PLUGIN_MANIFEST.description, }, ]), }), ); await page.route( `**/plugins-local/${TEST_PLUGIN_ID}/manifest.json`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(TEST_PLUGIN_RAW_MANIFEST), }), ); await page.route(`**/plugins-local/${TEST_PLUGIN_ID}/main.js`, (route) => route.fulfill({ status: 200, contentType: "text/javascript", body: this.localPluginSource ?? getTestPluginSource(), }), );
// Remote plugin registry routes — serve a fake registry and matching // GitHub release assets so flow tests can install remote plugins. await page.route( "**/improsocial/impro-releases/main/community-plugins.json", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(this.registryEntries), }), ); await page.route( "**/raw.githubusercontent.com/*/*/refs/tags/*/manifest.json", (route) => { const match = route .request() .url() .match(/refs\/tags\/([^/]+)\/manifest\.json$/); const version = match?.[1] ?? "0.0.0"; const id = this.registryEntries[0]?.id ?? "remote-plugin"; route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id, name: this.registryEntries[0]?.name ?? "Remote Plugin", version, description: this.registryEntries[0]?.description, }), }); }, ); await page.route( "**/raw.githubusercontent.com/*/*/refs/tags/*/main.js", (route) => route.fulfill({ status: 200, contentType: "text/javascript", body: getTestPluginSource(), }), ); await page.route( "**/raw.githubusercontent.com/*/*/refs/tags/*/styles.css", (route) => route.fulfill({ status: 404, body: "Not Found" }), ); await page.route( "**/raw.githubusercontent.com/*/*/refs/heads/main/manifest.json", (route) => { const live = this.liveManifest ?? { id: this.registryEntries[0]?.id ?? "remote-plugin", name: this.registryEntries[0]?.name ?? "Remote Plugin", version: "1.0.0", }; route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(live), }); }, ); // Plugin README, served from the repo's main branch (remote) or the // local plugin directory. const fulfillReadme = (route) => { if (this.pluginReadme === null) { route.fulfill({ status: 404, body: "Not Found" }); return; } route.fulfill({ status: 200, contentType: "text/markdown", body: this.pluginReadme, }); }; await page.route( "**/raw.githubusercontent.com/*/*/refs/heads/main/README.md", fulfillReadme, ); await page.route("**/plugins-local/*/README.md", fulfillReadme);
await page.route("**/.well-known/atproto-did*", (route) => route.fulfill({ status: 404, body: "Not Found" }), );
// The dummy notification service (see `notificationService` in // testData.js). Resolving one costs a DID document then a config // document, so both are served here. await page.route( `${notificationService.endpoint}/.well-known/did.json`, (route) => { if (this.notificationServiceUnreachable) { route.fulfill({ status: 500, body: "Server Error" }); return; } route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id: notificationService.did, service: [ { id: "#bsky_notif", type: "BskyNotificationService", serviceEndpoint: notificationService.endpoint, }, ], }), }); }, ); // The service's auth handoff. Real courier would run OAuth and redirect // back; this just stands still so tests can assert what was sent to it. await page.route(`${notificationService.authUrl}*`, (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>Authorize</title>", }), ); await page.route( `${notificationService.endpoint}/.well-known/notif-service.json`, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ name: notificationService.name, authUrl: notificationService.authUrl, vapidPublicKey: notificationService.vapidPublicKey, }), }), );
// OAuth token endpoint used by seeded test sessions. `invalid_request` // (not `invalid_grant`) fails immediately without the cross-tab // rotated-session recovery delay. await page.route("**/oauth/token*", (route) => { if (this.tokenRefreshShouldFail) { route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "invalid_request" }), }); return; } route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ access_token: "mock-access-token-refreshed", refresh_token: "mock-refresh-token-refreshed", expires_in: 3600, }), }); });
await page.route("**/xrpc/blue.microcosm.links.getBacklinks*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ records: [], cursor: "" }), }), );
await page.route("**/xrpc/com.atproto.server.getSession*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ did: userProfile.did, handle: userProfile.handle, }), }), );
await page.route("**/xrpc/app.bsky.actor.getPreferences*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ preferences: [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [ { type: "timeline", value: "following", pinned: true, id: "timeline-following", }, ...this.pinnedFeedUris.map((uri) => ({ type: "feed", value: uri, pinned: true, id: uri, })), ...this.pinnedListUris.map((uri) => ({ type: "list", value: uri, pinned: true, id: uri, })), ...this.savedFeedUris.map((uri) => ({ type: "feed", value: uri, pinned: false, id: uri, })), ], }, ...(this.hiddenPostUris.length > 0 ? [ { $type: "app.bsky.actor.defs#improHiddenPostsPref", items: this.hiddenPostUris, }, ] : []), ...(this.searchHistory ? [ { $type: "app.bsky.actor.defs#improSearchHistoryPref", ...this.searchHistory, }, ] : []), ...(this.labelerSubscriptions.length > 0 ? [ { $type: "app.bsky.actor.defs#labelersPref", labelers: this.labelerSubscriptions.map((did) => ({ did, })), }, ] : []), ...this.contentLabelPrefs, ...(this.mutedWords.length > 0 ? [ { $type: "app.bsky.actor.defs#mutedWordsPref", items: this.mutedWords, }, ] : []), ...[...this.pluginSettings.entries()].map(([pluginId, data]) => ({ $type: "app.bsky.actor.defs#improPluginSettingsPref", pluginId, data, })), ...(this.installedPlugins.length > 0 ? [ { $type: "app.bsky.actor.defs#improInstalledPluginsPref", plugins: this.installedPlugins, }, ] : []), ], }), }), );
await page.route("**/xrpc/app.bsky.labeler.getServices*", (route) => { const url = new URL(route.request().url()); const requestedDids = url.searchParams.getAll("dids"); const views = requestedDids.length > 0 ? this.labelerViews.filter((v) => requestedDids.includes(v.creator.did), ) : this.labelerViews; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ views }), }); });
await page.route("**/xrpc/app.bsky.notification.getUnreadCount*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ count: this.notifications.filter((n) => !n.isRead).length, }), }), );
await page.route( "**/xrpc/app.bsky.notification.listNotifications*", async (route) => { if (this.notificationsDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.notificationsDelayMs), ); } const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const reasons = url.searchParams.getAll("reasons");
const filteredNotifications = reasons.length > 0 ? this.notifications.filter((n) => reasons.includes(n.reason)) : this.notifications;
let notifications, nextCursor; if (limit) { notifications = filteredNotifications.slice(offset, offset + limit); nextCursor = offset + limit < filteredNotifications.length ? String(offset + limit) : ""; } else { notifications = filteredNotifications; nextCursor = this.notificationCursor || ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ notifications, cursor: nextCursor, ...(this.notificationsSeenAt ? { seenAt: this.notificationsSeenAt } : {}), }), }); }, );
await page.route("**/xrpc/app.bsky.notification.updateSeen*", (route) => { this.notificationsSeenAt = route.request().postDataJSON().seenAt; for (const notification of this.notifications) { notification.isRead = true; } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.notification.registerPush*", (route) => { this.registerPushCalls.push(route.request().postDataJSON()); return route.fulfill({ status: this.registerPushStatus, contentType: "text/plain", body: "", }); });
await page.route( "**/xrpc/app.bsky.notification.unregisterPush*", (route) => { this.unregisterPushCalls.push(route.request().postDataJSON()); return route.fulfill({ status: 200, contentType: "text/plain", body: "", }); }, );
await page.route("**/xrpc/app.bsky.feed.getPosts*", async (route) => { const url = new URL(route.request().url()); const uris = url.searchParams.getAll("uris"); const delayMs = Math.max( 0, ...uris.map((uri) => this.postDelays.get(uri) ?? 0), ); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } const posts = uris .map((uri) => this.posts.find((p) => p.uri === uri)) .filter(Boolean); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ posts }), }); });
await page.route("**/xrpc/chat.bsky.convo.getUnreadCounts*", (route) => { const unreadAcceptedConvos = this.convos.filter( (convo) => convo.unreadCount > 0 && convo.status !== "request", ).length; const unreadRequestConvos = this.convos.filter( (convo) => convo.unreadCount > 0 && convo.status === "request", ).length; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ unreadAcceptedConvos: Math.min(100, unreadAcceptedConvos), unreadRequestConvos: Math.min(100, unreadRequestConvos), }), }); });
await page.route("**/xrpc/chat.bsky.convo.listConvos*", (route) => { const url = new URL(route.request().url()); const readState = url.searchParams.get("readState"); const status = url.searchParams.get("status"); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; let allConvos = this.convos; if (readState === "unread") { allConvos = allConvos.filter((convo) => convo.unreadCount > 0); } if (status) { allConvos = allConvos.filter((convo) => convo.status === status); }
let convos, nextCursor; if (limit) { convos = allConvos.slice(offset, offset + limit); nextCursor = offset + limit < allConvos.length ? String(offset + limit) : ""; } else { convos = allConvos; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convos, cursor: nextCursor }), }); });
await page.route("**/xrpc/chat.bsky.convo.getConvo*", async (route) => { const url = new URL(route.request().url()); const convoId = url.searchParams.get("convoId"); const delayMs = this.convoDelays.get(convoId); if (delayMs) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } const convo = this.convos.find((c) => c.id === convoId); if (!convo) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "InvalidConvo", message: "Conversation not found", }), }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo }), }); });
await page.route("**/xrpc/chat.bsky.convo.getConvoMembers*", (route) => { if (this.convoMembersFailure) { const { status, error, message } = this.convoMembersFailure; return route.fulfill({ status, contentType: "application/json", body: JSON.stringify({ error, message }), }); } const url = new URL(route.request().url()); const convoId = url.searchParams.get("convoId"); const convo = this.convos.find((c) => c.id === convoId); if (!convo) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "InvalidConvo", message: "Conversation not found", }), }); } const allMembers = this.convoMemberLists.get(convoId) ?? convo.members; const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "50", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const members = allMembers.slice(offset, offset + limit); const nextCursor = offset + limit < allMembers.length ? String(offset + limit) : ""; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ members, ...(nextCursor ? { cursor: nextCursor } : {}), }), }); });
await page.route("**/xrpc/chat.bsky.convo.getMessages*", (route) => { const url = new URL(route.request().url()); const convoId = url.searchParams.get("convoId"); const messages = this.convoMessages.get(convoId) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ messages }), }); });
await page.route("**/xrpc/chat.bsky.convo.sendMessage*", (route) => { const body = route.request().postDataJSON(); this.sentMessageRequests.push(body); if (this.sendMessageFailure) { const { status, error, message } = this.sendMessageFailure; return route.fulfill({ status, contentType: "application/json", body: JSON.stringify({ error, message }), }); } const msgId = ++this.messageCounter; const sentMessage = { id: `msg-sent-${msgId}`, rev: `rev-sent-${msgId}`, text: body?.message?.text || "", sender: { did: userProfile.did }, sentAt: new Date().toISOString(), }; const convo = this.convos.find((c) => c.id === body?.convoId); if (convo) { convo.lastMessage = { $type: "chat.bsky.convo.defs#messageView", ...sentMessage, }; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(sentMessage), }); });
await page.route("**/xrpc/chat.bsky.convo.addReaction*", (route) => { const body = route.request().postDataJSON(); const { convoId, messageId, value } = body || {}; const messages = this.convoMessages.get(convoId) || []; const message = messages.find((m) => m.id === messageId); if (message) { message.reactions.push({ createdAt: new Date().toISOString(), sender: { did: userProfile.did }, value, }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: message || {} }), }); });
await page.route( "**/xrpc/chat.bsky.group.getJoinLinkPreviews*", (route) => { const url = new URL(route.request().url()); const codes = url.searchParams.getAll("codes"); const joinLinkPreviews = codes .map((code) => this.joinLinkPreviews.get(code)) .filter(Boolean); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ joinLinkPreviews }), }); }, );
await page.route("**/xrpc/chat.bsky.group.requestJoin*", (route) => { const body = route.request().postDataJSON(); const code = body?.code; if (this.failJoinLinkCodes.has(code)) { return route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "ServerError" }), }); } const preview = this.joinLinkPreviews.get(code); const joinedConvo = preview?.requireApproval === false ? this.joinLinkJoinedConvos.get(code) : null; if (preview?.$type === "chat.bsky.group.defs#joinLinkPreviewView") { this.joinLinkPreviews.set(code, { ...preview, ...(joinedConvo ? { convo: joinedConvo } : {}), viewer: { ...(preview.viewer ?? {}), ...(joinedConvo ? {} : { requestedAt: new Date().toISOString() }), }, }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( joinedConvo ? { status: "joined", convo: joinedConvo } : { status: "pending" }, ), }); });
await page.route("**/xrpc/chat.bsky.actor.getStatus*", (route) => { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(this.chatActorStatus), }); });
await page.route("**/xrpc/chat.bsky.group.createGroup*", (route) => { const body = route.request().postDataJSON(); this.createGroupRequests.push(body); if (this.createGroupError) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: this.createGroupError, message: this.createGroupError, }), }); } const memberDids = body?.members ?? []; const id = `convo-group-${++this.messageCounter}`; const memberKind = (role) => ({ $type: "chat.bsky.actor.defs#groupConvoMember", role, }); const convo = { id, rev: `rev-${id}`, members: [ { ...userProfile, kind: memberKind("owner") }, ...memberDids.map((did) => ({ ...(this.profiles.get(did) || { did }), kind: memberKind("standard"), })), ], status: "accepted", unreadCount: 0, muted: false, kind: { $type: "chat.bsky.convo.defs#groupConvo", name: body?.name, memberCount: memberDids.length + 1, memberLimit: this.chatActorStatus.groupMemberLimit ?? 100, lockStatus: "unlocked", createdAt: new Date().toISOString(), }, }; this.convos.push(convo); this.convoMessages.set(convo.id, []); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo }), }); });
await page.route("**/xrpc/chat.bsky.convo.removeReaction*", (route) => { const body = route.request().postDataJSON(); const { convoId, messageId, value } = body || {}; const messages = this.convoMessages.get(convoId) || []; const message = messages.find((m) => m.id === messageId); if (message) { message.reactions = message.reactions.filter( (r) => !(r.value === value && r.sender.did === userProfile.did), ); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: message || {} }), }); });
await page.route("**/xrpc/chat.bsky.convo.updateRead*", (route) => { const body = route.request().postDataJSON(); const convoId = body?.convoId; const convo = this.convos.find((c) => c.id === convoId); if (convo) { convo.unreadCount = 0; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo: convo || {} }), }); });
await page.route("**/xrpc/chat.bsky.convo.getLog*", (route) => { const logs = this.chatLogs.splice(0, this.chatLogs.length); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ logs, cursor: "" }), }); });
await page.route("**/xrpc/chat.bsky.convo.acceptConvo*", (route) => { const body = route.request().postDataJSON(); const convo = this.convos.find((c) => c.id === body?.convoId); if (convo) { convo.status = "accepted"; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ rev: "rev-accepted" }), }); });
await page.route("**/xrpc/chat.bsky.convo.leaveConvo*", (route) => { if (this.leaveConvoError) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: this.leaveConvoError, message: this.leaveConvoError, }), }); } const body = route.request().postDataJSON(); this.convos = this.convos.filter((c) => c.id !== body?.convoId); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ rev: "rev-left" }), }); });
await page.route("**/xrpc/chat.bsky.convo.muteConvo*", (route) => { const body = route.request().postDataJSON(); const convo = this.convos.find((c) => c.id === body?.convoId); if (convo) { convo.muted = true; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo: convo ?? null }), }); });
await page.route("**/xrpc/chat.bsky.convo.unmuteConvo*", (route) => { const body = route.request().postDataJSON(); const convo = this.convos.find((c) => c.id === body?.convoId); if (convo) { convo.muted = false; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo: convo ?? null }), }); });
await page.route( "**/xrpc/chat.bsky.convo.getConvoAvailability*", (route) => { const url = new URL(route.request().url()); const members = url.searchParams.getAll("members"); const otherDid = members.find((m) => m !== userProfile.did); const profile = this.profiles.get(otherDid); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ canChat: profile?.canChat ?? false }), }); }, );
await page.route("**/xrpc/chat.bsky.convo.getConvoForMembers*", (route) => { if (this.convoForMembersError) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: this.convoForMembersError, message: this.convoForMembersError, }), }); } const url = new URL(route.request().url()); const members = url.searchParams.getAll("members"); const otherDid = members.find((m) => m !== userProfile.did); // Find existing convo with this member const existingConvo = this.convos.find((c) => c.members.some((m) => m.did === otherDid), ); if (existingConvo) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo: existingConvo }), }); } // Create a new convo const profile = this.profiles.get(otherDid); const newConvo = { id: `convo-new-${++this.messageCounter}`, rev: `rev-new-${this.messageCounter}`, members: [userProfile, profile || { did: otherDid }], status: "accepted", unreadCount: 0, lastMessage: undefined, }; this.convos.push(newConvo); this.convoMessages.set(newConvo.id, []); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ convo: newConvo }), }); });
await page.route("**/xrpc/app.bsky.graph.muteActor*", (route) => { const body = route.request().postDataJSON(); const actor = body?.actor; const profile = this.profiles.get(actor); if (profile) { profile.viewer = { ...profile.viewer, muted: true }; } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.graph.unmuteActor*", (route) => { const body = route.request().postDataJSON(); const actor = body?.actor; const profile = this.profiles.get(actor); if (profile) { profile.viewer = { ...profile.viewer, muted: false }; } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.graph.muteActorList*", (route) => { const body = route.request().postDataJSON(); const listUri = body?.list; const list = this.lists.find((l) => l.uri === listUri); if (list) { list.viewer = { ...list.viewer, muted: true }; } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.graph.unmuteActorList*", (route) => { const body = route.request().postDataJSON(); const listUri = body?.list; const list = this.lists.find((l) => l.uri === listUri); if (list) { list.viewer = { ...list.viewer, muted: false }; } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route( "**/xrpc/app.bsky.notification.putActivitySubscription*", (route) => { const body = route.request().postDataJSON(); const subject = body?.subject; const activitySubscription = body?.activitySubscription; const profile = this.profiles.get(subject); if (profile) { profile.viewer = { ...profile.viewer, activitySubscription }; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ subject, activitySubscription }), }); }, );
await page.route("**/xrpc/app.bsky.feed.getActorLikes*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allPosts = this.authorFeeds.get(`${actor}-likes`) || [];
let posts, nextCursor; if (limit) { posts = allPosts.slice(offset, offset + limit); nextCursor = offset + limit < allPosts.length ? String(offset + limit) : ""; } else { posts = allPosts; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feed: posts.map((post) => ({ post })), cursor: nextCursor, }), }); });
await page.route("**/xrpc/app.bsky.feed.getAuthorFeed*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const filter = url.searchParams.get("filter") || ""; const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allPosts = this.authorFeeds.get(`${actor}-${filter}`) || [];
let posts, nextCursor; if (limit) { posts = allPosts.slice(offset, offset + limit); nextCursor = offset + limit < allPosts.length ? String(offset + limit) : ""; } else { posts = allPosts; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feed: posts.map((post) => ({ post })), cursor: nextCursor, }), }); });
await page.route("**/xrpc/app.bsky.actor.getProfile*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const profile = this.profiles.get(actor) || userProfile; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(profile), }); });
await page.route("**/xrpc/app.bsky.actor.getProfiles*", async (route) => { if (this.getProfilesDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.getProfilesDelayMs), ); } const url = new URL(route.request().url()); const actors = url.searchParams.getAll("actors"); const profiles = actors .map((actor) => this.profiles.get(actor)) .filter(Boolean); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ profiles }), }); });
await page.route("**/xrpc/app.bsky.bookmark.getBookmarks*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ bookmarks: this.bookmarks.map((post) => ({ item: post })), }), }), );
await page.route("**/xrpc/app.bsky.bookmark.createBookmark*", (route) => { const body = route.request().postDataJSON(); const postUri = body?.uri; const allPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const post = allPosts.find((p) => p.uri === postUri); if (post) { post.viewer.bookmarked = true; if (!this.bookmarks.includes(post)) { this.bookmarks.push(post); } } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.bookmark.deleteBookmark*", (route) => { const body = route.request().postDataJSON(); const postUri = body?.uri; const idx = this.bookmarks.findIndex((p) => p.uri === postUri); if (idx !== -1) { this.bookmarks[idx].viewer.bookmarked = false; this.bookmarks.splice(idx, 1); } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.draft.getDrafts*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ drafts: this.drafts }), }), );
await page.route("**/xrpc/app.bsky.draft.createDraft*", (route) => { if (this.draftLimitReached) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "DraftLimitReached", message: "Draft limit reached", }), }); } const body = route.request().postDataJSON(); const id = `draft-${++this.draftCounter}`; const now = new Date().toISOString(); this.drafts.unshift({ id, createdAt: now, updatedAt: now, draft: body.draft, }); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id }), }); });
await page.route("**/xrpc/app.bsky.draft.updateDraft*", (route) => { const body = route.request().postDataJSON(); const existing = this.drafts.find( (draftView) => draftView.id === body.draft.id, ); if (existing) { existing.draft = body.draft.draft; existing.updatedAt = new Date().toISOString(); } return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.draft.deleteDraft*", (route) => { const body = route.request().postDataJSON(); this.deletedDraftIds.push(body.id); this.drafts = this.drafts.filter((draftView) => draftView.id !== body.id); return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/app.bsky.actor.searchActors*", (route) => { this.searchRequestCounts.profiles += 1; const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0;
let actors, nextCursor; if (limit) { actors = this.searchProfiles.slice(offset, offset + limit); nextCursor = offset + limit < this.searchProfiles.length ? String(offset + limit) : ""; } else { actors = this.searchProfiles; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ actors, cursor: nextCursor }), }); });
await page.route( "**/xrpc/app.bsky.unspecced.getPopularFeedGenerators*", (route) => { this.searchRequestCounts.feeds += 1; const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0;
let feeds, nextCursor; if (limit) { feeds = this.searchFeedGenerators.slice(offset, offset + limit); nextCursor = offset + limit < this.searchFeedGenerators.length ? String(offset + limit) : ""; } else { feeds = this.searchFeedGenerators; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feeds, cursor: nextCursor }), }); }, );
// The trending pane renders on nearly every desktop page, so this must be // registered unconditionally or unrelated specs fail on an unmocked request. await page.route("**/xrpc/app.bsky.unspecced.getTrends*", (route) => { const url = new URL(route.request().url()); const limit = parseInt(url.searchParams.get("limit") || "0", 10); const trends = limit ? this.trends.slice(0, limit) : this.trends; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ trends }), }); });
await page.route("**/xrpc/app.bsky.feed.getActorFeeds*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor") || ""; const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allFeeds = this.actorFeeds.get(actor) || [];
let feeds, nextCursor; if (limit) { feeds = allFeeds.slice(offset, offset + limit); nextCursor = offset + limit < allFeeds.length ? String(offset + limit) : ""; } else { feeds = allFeeds; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feeds, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.feed.searchPosts*", (route) => { const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const sort = url.searchParams.get("sort") || "top"; this.searchRequestCounts[sort] += 1; const sortedPosts = this.searchPostsBySort[sort] ?? this.searchPosts;
let posts, nextCursor; if (limit) { posts = sortedPosts.slice(offset, offset + limit); nextCursor = offset + limit < sortedPosts.length ? String(offset + limit) : ""; } else { posts = sortedPosts; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ posts, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.feed.getTimeline*", async (route) => { if (this.timelineDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.timelineDelayMs), ); } const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0;
const blockedDids = new Set(); for (const [did, profile] of this.profiles) { if (profile.viewer?.blocking || profile.viewer?.muted) { blockedDids.add(did); } } const allPosts = this.timelinePosts.filter( (post) => !blockedDids.has(post.author?.did), );
let posts, nextCursor; if (limit) { posts = allPosts.slice(offset, offset + limit); nextCursor = offset + limit < allPosts.length ? String(offset + limit) : ""; } else { posts = allPosts; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feed: posts.map((post) => ({ post })), cursor: nextCursor, }), }); });
await page.route("**/xrpc/app.bsky.feed.getListFeed*", (route) => { const url = new URL(route.request().url()); const listUri = url.searchParams.get("list"); const feed = this.feeds.get(listUri) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feed, cursor: "" }), }); });
// Order matters: Playwright checks routes in LIFO order, so register // the most general pattern first (checked last) and most specific last. await page.route("**/xrpc/app.bsky.feed.getFeed*", (route) => { const url = new URL(route.request().url()); const feedUri = url.searchParams.get("feed"); const feed = this.feeds.get(feedUri) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feed, cursor: "" }), }); });
await page.route("**/xrpc/app.bsky.feed.getFeedGenerator*", (route) => { const url = new URL(route.request().url()); const feedUri = url.searchParams.get("feed"); const generator = this.feedGenerators.find((g) => g.uri === feedUri); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ view: generator || {} }), }); });
await page.route("**/xrpc/app.bsky.feed.getFeedGenerators*", (route) => { const url = new URL(route.request().url()); const feedUris = url.searchParams.getAll("feeds"); const feeds = feedUris.map( (uri) => this.feedGenerators.find((g) => g.uri === uri) || {}, ); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ feeds }), }); });
await page.route("**/xrpc/app.bsky.graph.getList*", (route) => { const url = new URL(route.request().url()); const listUri = url.searchParams.get("list"); const list = this.lists.find((l) => l.uri === listUri) || {}; const members = this.listMembers.get(listUri) || []; const items = members.map((profile) => ({ subject: profile })); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ list, items, cursor: "" }), }); });
for (const cdnUrl of new Set(["https://cdn.bsky.app", CDN_URL])) { await page.route(`${cdnUrl}/img/**`, (route) => { return route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ), }); }); }
await page.route("https://ogcard.cdn.bsky.app/**", (route) => { return route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64", ), }); });
await page.route("**/xrpc/app.bsky.graph.getStarterPack?*", (route) => { const url = new URL(route.request().url()); const starterPackUri = url.searchParams.get("starterPack"); const starterPack = this.starterPacks.find((s) => s.uri === starterPackUri) || {}; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ starterPack }), }); });
await page.route("**/xrpc/app.bsky.graph.getLists*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor") || ""; const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allLists = this.actorLists.get(actor) || [];
let lists, nextCursor; if (limit) { lists = allLists.slice(offset, offset + limit); nextCursor = offset + limit < allLists.length ? String(offset + limit) : ""; } else { lists = allLists; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ lists, cursor: nextCursor }), }); });
await page.route( "**/xrpc/app.bsky.graph.getListsWithMembership*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const limit = parseInt(url.searchParams.get("limit") || "50", 10); const cursorParam = url.searchParams.get("cursor") || ""; const start = cursorParam ? parseInt(cursorParam, 10) : 0; const lists = this.actorLists.get(userProfile.did) || []; const page = lists.slice(start, start + limit); const listsWithMembership = page.map((list) => { const item = this.currentUserListItems.find( (entry) => entry.listUri === list.uri && entry.subjectDid === actor, ); return item ? { list, listItem: { uri: item.uri, subject: actor } } : { list }; }); const nextStart = start + page.length; const cursor = nextStart < lists.length ? String(nextStart) : ""; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ listsWithMembership, cursor }), }); }, );
await page.route("**/xrpc/app.bsky.feed.getLikes*", (route) => { const url = new URL(route.request().url()); const uri = url.searchParams.get("uri"); const likes = this.postLikes.get(uri) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ likes, cursor: "" }), }); });
await page.route("**/xrpc/app.bsky.feed.getQuotes*", (route) => { const url = new URL(route.request().url()); const uri = url.searchParams.get("uri"); const posts = this.postQuotes.get(uri) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ posts, cursor: "" }), }); });
await page.route("**/xrpc/app.bsky.feed.getRepostedBy*", (route) => { const url = new URL(route.request().url()); const uri = url.searchParams.get("uri"); const repostedBy = this.postReposts.get(uri) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ repostedBy, cursor: "" }), }); });
await page.route("**/xrpc/app.bsky.feed.getPostThread*", async (route) => { const url = new URL(route.request().url()); const uri = url.searchParams.get("uri"); const customThread = this.postThreads.get(uri); if (customThread) { const delayMs = this.postThreadDelays.get(uri); if (delayMs) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ thread: customThread }), }); } const allPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const post = allPosts.find((p) => p.uri === uri); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: post || {}, replies: [], }, }), }); });
await page.route( "**/xrpc/app.bsky.unspecced.getPostThreadOtherV2*", (route) => { const url = new URL(route.request().url()); const anchor = url.searchParams.get("anchor"); const customThreadOther = this.postThreadOthers.get(anchor); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ thread: customThreadOther || [] }), }); }, );
await page.route("**/xrpc/app.bsky.graph.getFollowers*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allFollowers = this.profileFollowers.get(actor) || [];
let followers, nextCursor; if (limit) { followers = allFollowers.slice(offset, offset + limit); nextCursor = offset + limit < allFollowers.length ? String(offset + limit) : ""; } else { followers = allFollowers; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ followers, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.graph.getKnownFollowers*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allFollowers = this.knownFollowers.get(actor) || [];
let followers, nextCursor; if (limit) { followers = allFollowers.slice(offset, offset + limit); nextCursor = offset + limit < allFollowers.length ? String(offset + limit) : ""; } else { followers = allFollowers; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ followers, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.graph.getFollows*", (route) => { const url = new URL(route.request().url()); const actor = url.searchParams.get("actor"); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0; const allFollows = this.profileFollows.get(actor) || [];
let follows, nextCursor; if (limit) { follows = allFollows.slice(offset, offset + limit); nextCursor = offset + limit < allFollows.length ? String(offset + limit) : ""; } else { follows = allFollows; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ follows, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.graph.getBlocks*", (route) => { const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0;
let blocks, nextCursor; if (limit) { blocks = this.blockedProfiles.slice(offset, offset + limit); nextCursor = offset + limit < this.blockedProfiles.length ? String(offset + limit) : ""; } else { blocks = this.blockedProfiles; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ blocks, cursor: nextCursor }), }); });
await page.route("**/xrpc/app.bsky.graph.getMutes*", (route) => { const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor") || ""; const limit = parseInt(url.searchParams.get("limit") || "0", 10); const offset = cursor ? parseInt(cursor, 10) : 0;
let mutes, nextCursor; if (limit) { mutes = this.mutedProfiles.slice(offset, offset + limit); nextCursor = offset + limit < this.mutedProfiles.length ? String(offset + limit) : ""; } else { mutes = this.mutedProfiles; nextCursor = ""; }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ mutes, cursor: nextCursor }), }); });
await page.route("**/xrpc/com.atproto.identity.resolveHandle*", (route) => { const url = new URL(route.request().url()); const handle = url.searchParams.get("handle"); const identity = this._findIdentity(handle); if (!identity) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "InvalidRequest", message: "Unable to resolve handle", }), }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ did: identity.did }), }); });
// Slingshot is the first identity provider the app tries; set // slingshotUnreachable to force the atproto fallback path instead. await page.route( "**/xrpc/blue.microcosm.identity.resolveMiniDoc*", (route) => { const invalidRequest = (message) => route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "InvalidRequest", message }), }); if (this.slingshotUnreachable) { return invalidRequest( "Errored while trying to resolve handle to DID", ); } const url = new URL(route.request().url()); const identifier = url.searchParams.get("identifier"); const identity = this._findIdentity(identifier); if (!identity) { return invalidRequest("Failed to resolve identity"); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ did: identity.did, handle: identity.handle, pds: this.pdsEndpoint, }), }); }, );
await page.route("**/xrpc/com.atproto.repo.deleteRecord*", (route) => { const body = route.request().postDataJSON(); const collection = body?.collection; const rkey = body?.rkey;
if (collection === "app.bsky.feed.like") { const feedKey = `${userProfile.did}-likes`; const likes = this.authorFeeds.get(feedKey) || []; this.authorFeeds.set( feedKey, likes.filter((p) => p.viewer?.like?.split("/").pop() !== rkey), ); }
if (collection === "app.bsky.feed.repost") { const allPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; for (const post of allPosts) { if ( post.viewer?.repost && post.viewer.repost.split("/").pop() === rkey ) { delete post.viewer.repost; post.repostCount = Math.max(0, (post.repostCount || 0) - 1); const feedKey = `${userProfile.did}-posts_and_author_threads`; const existing = this.authorFeeds.get(feedKey) || []; this.authorFeeds.set( feedKey, existing.filter((p) => p !== post), ); break; } } }
if (collection === "app.bsky.graph.follow") { for (const [did, profile] of this.profiles) { if ( profile.viewer?.following && profile.viewer.following.split("/").pop() === rkey ) { profile.viewer = { ...profile.viewer, following: undefined }; if ( profile.followersCount !== undefined && profile.followersCount > 0 ) { profile.followersCount--; } const follows = this.profileFollows.get(userProfile.did) || []; this.profileFollows.set( userProfile.did, follows.filter((p) => p.did !== did), ); break; } } }
if (collection === "app.bsky.graph.block") { for (const [, profile] of this.profiles) { if ( profile.viewer?.blocking && profile.viewer.blocking.split("/").pop() === rkey ) { profile.viewer = { ...profile.viewer, blocking: undefined }; break; } } }
if (collection === "app.bsky.graph.listblock") { for (const list of this.lists) { if ( list.viewer?.blocked && list.viewer.blocked.split("/").pop() === rkey ) { list.viewer = { ...list.viewer, blocked: undefined }; break; } } }
if (collection === "app.bsky.feed.post") { const postUri = `at://${userProfile.did}/${collection}/${rkey}`; this.timelinePosts = this.timelinePosts.filter( (p) => p.uri !== postUri, ); this.posts = this.posts.filter((p) => p.uri !== postUri); for (const [key, posts] of this.authorFeeds) { this.authorFeeds.set( key, posts.filter((p) => p.uri !== postUri), ); } }
if (collection === "app.bsky.graph.listitem") { const itemUri = `at://${userProfile.did}/${collection}/${rkey}`; const removed = this.currentUserListItems.find( (item) => item.uri === itemUri, ); this.currentUserListItems = this.currentUserListItems.filter( (item) => item.uri !== itemUri, ); if (removed) { const members = this.listMembers.get(removed.listUri); if (members) { this.listMembers.set( removed.listUri, members.filter((profile) => profile.did !== removed.subjectDid), ); } } }
return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/com.atproto.repo.listRecords*", (route) => { const url = new URL(route.request().url()); const collection = url.searchParams.get("collection"); if (collection === "app.bsky.graph.listitem") { const records = this.currentUserListItems.map((item) => ({ uri: item.uri, cid: `bafyrei${item.uri.split("/").pop()}`, value: { $type: "app.bsky.graph.listitem", subject: item.subjectDid, list: item.listUri, createdAt: "2025-01-01T00:00:00.000Z", }, })); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ records, cursor: "" }), }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ records: [], cursor: "" }), }); });
await page.route("**/xrpc/com.atproto.repo.applyWrites*", (route) => { const body = route.request().postDataJSON(); const writes = body?.writes ?? []; this.applyWritesCalls.push(writes); for (const write of writes) { if (write.collection !== "app.bsky.feed.post") continue; const uri = `at://${userProfile.did}/app.bsky.feed.post/${write.rkey}`; this._ingestPostRecord(write.value, uri); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ results: [] }), }); });
await page.route("**/xrpc/com.atproto.repo.createRecord*", (route) => { const body = route.request().postDataJSON(); const collection = body?.collection; const failure = this.createRecordFailures.get(collection); if (failure) { const { status, error, message } = failure; route.fulfill({ status, contentType: "application/json", body: JSON.stringify(message ? { error, message } : { error }), }); return; } const rkey = `rkey-${++this.createRecordCounter}`; const uri = `at://${userProfile.did}/${collection}/${rkey}`; const cid = `bafyrei${rkey}`;
if (collection === "app.bsky.graph.listitem") { const record = body?.record || {}; this.currentUserListItems.push({ uri, listUri: record.list, subjectDid: record.subject, }); const profile = this.profiles.get(record.subject); if (profile) { const members = this.listMembers.get(record.list) || []; if (!members.some((p) => p.did === profile.did)) { this.listMembers.set(record.list, [profile, ...members]); } } }
if (collection === "app.bsky.feed.post") { this._ingestPostRecord(body?.record, uri); }
if (collection === "app.bsky.feed.like") { const subjectUri = body?.record?.subject?.uri; const allPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const post = allPosts.find((p) => p.uri === subjectUri); if (post) { post.viewer.like = uri; const feedKey = `${userProfile.did}-likes`; const existing = this.authorFeeds.get(feedKey) || []; this.authorFeeds.set(feedKey, [...existing, post]); } }
if (collection === "app.bsky.feed.repost") { const subjectUri = body?.record?.subject?.uri; const allPosts = [ ...this.timelinePosts, ...this.bookmarks, ...this.searchPosts, ...this.posts, ]; const post = allPosts.find((p) => p.uri === subjectUri); if (post) { post.viewer.repost = uri; post.repostCount = (post.repostCount || 0) + 1; const feedKey = `${userProfile.did}-posts_and_author_threads`; const existing = this.authorFeeds.get(feedKey) || []; this.authorFeeds.set(feedKey, [post, ...existing]); } }
if (collection === "app.bsky.graph.follow") { const subjectDid = body?.record?.subject; const profile = this.profiles.get(subjectDid); if (profile) { profile.viewer = { ...profile.viewer, following: uri }; if (profile.followersCount !== undefined) { profile.followersCount++; } const follows = this.profileFollows.get(userProfile.did) || []; if (!follows.find((p) => p.did === subjectDid)) { follows.push(profile); this.profileFollows.set(userProfile.did, follows); } } }
if (collection === "app.bsky.graph.block") { const subjectDid = body?.record?.subject; const profile = this.profiles.get(subjectDid); if (profile) { profile.viewer = { ...profile.viewer, blocking: uri }; } }
if (collection === "app.bsky.graph.listblock") { const subjectUri = body?.record?.subject; const list = this.lists.find((l) => l.uri === subjectUri); if (list) { list.viewer = { ...list.viewer, blocked: uri }; } }
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri, cid }), }); });
await page.route( "**/xrpc/app.bsky.actor.putPreferences*", async (route) => { if (this.putPreferencesDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.putPreferencesDelayMs), ); } const body = route.request().postDataJSON(); const savedFeedsPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#savedFeedsPrefV2", ); if (savedFeedsPref) { this.pinnedFeedUris = savedFeedsPref.items .filter((item) => item.type === "feed" && item.pinned) .map((item) => item.value); this.savedFeedUris = savedFeedsPref.items .filter((item) => item.type === "feed" && !item.pinned) .map((item) => item.value); } const hiddenPostsPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#improHiddenPostsPref", ); if (hiddenPostsPref) { this.hiddenPostUris = hiddenPostsPref.items || []; } const searchHistoryPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#improSearchHistoryPref", ); if (searchHistoryPref) { this.searchHistory = { searches: searchHistoryPref.searches || [], profiles: searchHistoryPref.profiles || [], }; } const labelersPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#labelersPref", ); if (labelersPref) { this.labelerSubscriptions = labelersPref.labelers.map((l) => l.did); } else { this.labelerSubscriptions = []; } this.contentLabelPrefs = (body?.preferences || []).filter( (p) => p.$type === "app.bsky.actor.defs#contentLabelPref", ); const mutedWordsPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#mutedWordsPref", ); if (mutedWordsPref) { this.mutedWords = mutedWordsPref.items || []; } const installedPluginsPref = body?.preferences?.find( (p) => p.$type === "app.bsky.actor.defs#improInstalledPluginsPref", ); if (installedPluginsPref) { this.installedPlugins = installedPluginsPref.plugins || []; } const pluginSettingsPrefs = (body?.preferences || []).filter( (p) => p.$type === "app.bsky.actor.defs#improPluginSettingsPref", ); this.pluginSettings = new Map( pluginSettingsPrefs.map((p) => [p.pluginId, p.data]), ); return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); }, );
await page.route( "**/xrpc/com.atproto.moderation.createReport*", (route) => { const body = route.request().postDataJSON(); this.reportPayloads.push(body); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id: this.reportPayloads.length, reasonType: body?.reasonType, subject: body?.subject, reportedBy: userProfile.did, createdAt: new Date().toISOString(), }), }); }, );
await page.route("**/xrpc/app.bsky.feed.sendInteractions*", (route) => { const body = route.request().postDataJSON(); this.interactionPayloads.push(body); return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); });
await page.route("**/xrpc/com.atproto.repo.getRecord*", (route) => { const url = new URL(route.request().url()); const collection = url.searchParams.get("collection"); const rkey = url.searchParams.get("rkey"); if (collection === "app.bsky.actor.profile" && rkey === "self") { const profile = this.profiles.get(userProfile.did) || userProfile; const value = { $type: "app.bsky.actor.profile", displayName: profile.displayName || "", description: profile.description || "", }; if (profile.pinnedPost) { value.pinnedPost = profile.pinnedPost; } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: `at://${userProfile.did}/${collection}/${rkey}`, cid: "bafyreiprofilerecord", value, }), }); } if (collection === "app.bsky.graph.list") { const repo = url.searchParams.get("repo"); const listUri = `at://${repo}/${collection}/${rkey}`; const list = this.lists.find((l) => l.uri === listUri); if (list) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: listUri, cid: list.cid || "bafyreilistrecord", value: { $type: "app.bsky.graph.list", purpose: list.purpose, name: list.name, description: list.description || "", createdAt: list.indexedAt || "2024-01-01T00:00:00.000Z", }, }), }); } } if ( collection === "app.bsky.feed.threadgate" || collection === "app.bsky.feed.postgate" ) { const repo = url.searchParams.get("repo"); const records = collection === "app.bsky.feed.threadgate" ? this.threadgateRecords : this.postgateRecords; const record = records.get(rkey); if (record) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: `at://${repo}/${collection}/${rkey}`, cid: record.cid, value: record.value, }), }); } return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "RecordNotFound", message: `Could not locate record: at://${repo}/${collection}/${rkey}`, }), }); } return route.fulfill({ status: 404, body: "{}" }); });
await page.route("**/xrpc/com.atproto.repo.putRecord*", (route) => { const body = route.request().postDataJSON(); const collection = body?.collection; if (collection === "app.bsky.actor.profile") { const record = body?.record || {}; const profile = this.profiles.get(userProfile.did) || { ...userProfile, }; profile.displayName = record.displayName || ""; profile.description = record.description || ""; if (record.avatar) { profile.avatar = "mock-avatar-url"; } else if (!record.avatar && record.avatar !== undefined) { profile.avatar = ""; } if (record.banner) { profile.banner = "mock-banner-url"; } else if (!record.banner && record.banner !== undefined) { profile.banner = ""; } if (record.pinnedPost) { profile.pinnedPost = record.pinnedPost; } else { delete profile.pinnedPost; } this.profiles.set(userProfile.did, profile); } if ( collection === "app.bsky.feed.threadgate" || collection === "app.bsky.feed.postgate" ) { const repo = body?.repo; const rkey = body?.rkey; const isThreadgate = collection === "app.bsky.feed.threadgate"; const calls = isThreadgate ? this.putThreadgateCalls : this.putPostgateCalls; const records = isThreadgate ? this.threadgateRecords : this.postgateRecords; const cid = isThreadgate ? "bafyreiupdatedthreadgate" : "bafyreiupdatedpostgate"; calls.push(body); records.set(rkey, { cid, value: body?.record }); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: `at://${repo}/${collection}/${rkey}`, cid, }), }); } if (collection === "app.bsky.graph.list") { const repo = body?.repo; const rkey = body?.rkey; const listUri = `at://${repo}/${collection}/${rkey}`; // Intentionally do NOT mutate the list here: the client patches its // local list from the record it just wrote, matching real bsky // AppView behavior (which briefly returns stale data after putRecord). return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: listUri, cid: "bafyreiupdatedlist", }), }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: `at://${userProfile.did}/${collection}/self`, cid: "bafyreiupdatedrecord", }), }); });
await page.route("**/xrpc/com.atproto.repo.uploadBlob*", async (route) => { if (this.blobUploadDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.blobUploadDelayMs), ); } // The client can abort an upload (cancelling a send), which makes // fulfilling a no-longer-pending route throw. await route .fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ blob: { $type: "blob", // suffix must stay within the base32 alphabet (computeRecordCid // rejects invalid CID characters like "0", "1", "8", "9") ref: { $link: `bafkreimockblob${String(++this.blobCounter) .split("") .map((digit) => "abcdefghij"[digit]) .join("")}`, }, mimeType: "image/jpeg", size: 50000, }, }), }) .catch(() => {}); });
await page.route("**/xrpc/com.atproto.server.getServiceAuth*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ token: "mock-service-auth-token" }), }), );
await page.route("**/xrpc/app.bsky.video.getUploadLimits*", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ canUpload: this.videoCanUpload !== false, remainingDailyVideos: 25, remainingDailyBytes: 100_000_000, message: this.videoUploadMessage || "", }), }), );
await page.route("**/xrpc/app.bsky.video.uploadVideo*", (route) => { this.videoJobCounter = (this.videoJobCounter || 0) + 1; const jobId = `mock-video-job-${this.videoJobCounter}`; this.videoJobPollCounts = this.videoJobPollCounts || new Map(); this.videoJobPollCounts.set(jobId, 0); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ jobId, did: userProfile.did, state: "JOB_STATE_ENCODING", progress: 0, }), }); });
await page.route("**/xrpc/app.bsky.video.getJobStatus*", (route) => { const url = new URL(route.request().url()); const jobId = url.searchParams.get("jobId"); if (this.videoJobShouldFail) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ jobStatus: { jobId, did: userProfile.did, state: "JOB_STATE_FAILED", progress: 0, error: "mock failure", message: "mock failure", }, }), }); } this.videoJobPollCounts = this.videoJobPollCounts || new Map(); const count = (this.videoJobPollCounts.get(jobId) || 0) + 1; this.videoJobPollCounts.set(jobId, count); // Complete on the second poll if (count >= 2) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ jobStatus: { jobId, did: userProfile.did, state: "JOB_STATE_COMPLETED", progress: 1, blob: { $type: "blob", ref: { $link: `bafkreimockvideo${this.videoJobCounter}` }, mimeType: "video/mp4", size: 1024, }, }, }), }); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ jobStatus: { jobId, did: userProfile.did, state: "JOB_STATE_ENCODING", progress: 0.5, }, }), }); });
await page.route( (url) => url.toString().includes("cardyb.bsky.app/v1/extract"), (route) => { const url = new URL(route.request().url()); const targetUrl = url.searchParams.get("url"); const meta = this.externalLinkCards.get(targetUrl) || { title: targetUrl, description: "", }; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(meta), }); }, );
await page.route( "**/xrpc/app.bsky.actor.searchActorsTypeahead*", async (route) => { this.searchRequestCounts.typeahead += 1; if (this.typeaheadDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, this.typeaheadDelayMs), ); } return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ actors: this.typeaheadProfiles }), }); }, );
// Registered last so it wins over any broader route (later routes match // first). The app's entry module is content-hashed to /js/app.<hash>.js. if (this.appScriptFailure || this.appScriptDelayMs) { await page.route("**/js/app.*.js", async (route) => { if (this.appScriptFailure) { return route.abort(); } await new Promise((resolve) => setTimeout(resolve, this.appScriptDelayMs), ); return route.fallback(); }); } }}
export class MockConstellation { constructor() { this.backlinks = new Map(); }
setBacklinks(subject, records) { this.backlinks.set(subject, records); }
async setup(page) { await page.route("**/xrpc/blue.microcosm.links.getBacklinks*", (route) => { const url = new URL(route.request().url()); const subject = url.searchParams.get("subject"); const records = this.backlinks.get(subject) || []; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ records, cursor: "" }), }); }); }}