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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254import { 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"; // did -> { cid, value } for app.bsky.actor.status/self this.statusRecords = new Map(); this.putStatusCalls = []; this.deleteStatusCalls = []; // Remaining InvalidSwap failures the next putStatusRecord calls will emit this.invalidSwapRemaining = 0; this.statusCounter = 0; }
// 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); }
// Seeds a status record for the given did (default: current user). setStatusRecord( record, { did = userProfile.did, cid = "bafyreiseedstatus" } = {}, ) { this.statusRecords.set(did, { cid, value: record }); }
// Makes the next N putStatusRecord calls fail with InvalidSwap before // succeeding. Consumed one-per-call. simulateInvalidSwap({ times = 1 } = {}) { this.invalidSwapRemaining = times; }
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.actor.status") { const repo = body?.repo; this.deleteStatusCalls.push(body); if (!this.statusRecords.has(repo)) { return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "RecordNotFound", message: "Could not locate record", }), }); } this.statusRecords.delete(repo); return route.fulfill({ status: 200, contentType: "application/json", body: "{}", }); }
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.actor.status" && rkey === "self") { const repo = url.searchParams.get("repo"); const seeded = this.statusRecords.get(repo); if (seeded) { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ uri: `at://${repo}/${collection}/${rkey}`, cid: seeded.cid, value: seeded.value, }), }); } return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "RecordNotFound", message: `Could not locate record: at://${repo}/${collection}/${rkey}`, }), }); } 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.actor.status") { const repo = body?.repo; const rkey = body?.rkey ?? "self"; this.putStatusCalls.push(body); if (this.invalidSwapRemaining > 0) { this.invalidSwapRemaining -= 1; return route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ error: "InvalidSwap", message: "Record swap CID does not match current CID", }), }); } this.statusCounter += 1; const cid = `bafyreistatus${this.statusCounter}`; this.statusRecords.set(repo, { 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: "" }), }); }); }}