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