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: "" }),
});
});
}
}