451 lines
13 KiB
JavaScript
451 lines
13 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { tweetLegacyFixture } from "./fixtures/twitter.ts";
|
|
|
|
const portValue = process.env.TWITTER_LITE_MOCK_RELAY_PORT;
|
|
const port = Number(portValue);
|
|
|
|
if (!portValue || !Number.isInteger(port) || port < 1) {
|
|
throw new Error("TWITTER_LITE_MOCK_RELAY_PORT must be a valid port");
|
|
}
|
|
|
|
const failedRetryQueries = new Set();
|
|
const rootByTarget = new Map([
|
|
["1001", "1000"],
|
|
["2001", "2000"],
|
|
["3001", "3000"],
|
|
]);
|
|
const targetByRoot = new Map([
|
|
["1000", "1001"],
|
|
["2000", "2001"],
|
|
["3000", "3001"],
|
|
]);
|
|
const failedThreadRoots = new Set();
|
|
|
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const tweetResult = (
|
|
id,
|
|
text,
|
|
username = "yuta",
|
|
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
|
) => ({
|
|
rest_id: id,
|
|
legacy: tweetLegacyFixture(id, text, {
|
|
conversationId,
|
|
inReplyTo,
|
|
replyCount,
|
|
}),
|
|
core: {
|
|
user_results: {
|
|
result: {
|
|
rest_id: "42",
|
|
legacy: { screen_name: username, name: "Yuta" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const tweet = (id, text, username = "yuta", options) => ({
|
|
entryId: `tweet-${id}`,
|
|
content: {
|
|
itemContent: {
|
|
tweet_results: {
|
|
result: tweetResult(id, text, username, options),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const cursor = (value) => ({
|
|
entryId: "cursor-bottom",
|
|
content: { cursorType: "Bottom", value },
|
|
});
|
|
|
|
const send = (response, payload, status = 200, headers = {}) => {
|
|
response.writeHead(status, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
...headers,
|
|
});
|
|
response.end(JSON.stringify(payload));
|
|
};
|
|
|
|
const fail = (response, status, message, headers) =>
|
|
send(response, { errors: [{ message }] }, status, headers);
|
|
|
|
const variablesFrom = (url) => {
|
|
const encoded = url.searchParams.get("variables");
|
|
if (!encoded) throw new Error("variables are required");
|
|
const variables = JSON.parse(encoded);
|
|
if (!isRecord(variables)) throw new Error("variables must be an object");
|
|
return variables;
|
|
};
|
|
|
|
const jsonBodyFrom = async (request) => {
|
|
let body = "";
|
|
for await (const chunk of request) {
|
|
body += chunk;
|
|
if (body.length > 1_000_000) throw new Error("request body is too large");
|
|
}
|
|
const parsed = JSON.parse(body);
|
|
if (!isRecord(parsed)) throw new Error("JSON body must be an object");
|
|
return parsed;
|
|
};
|
|
|
|
const timeline = (entries) => ({ instructions: [{ entries }] });
|
|
|
|
const server = createServer(async (request, response) => {
|
|
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
|
if (request.method === "GET" && url.pathname === "/profiles") {
|
|
send(response, { profiles: ["e2e", "alternate"] });
|
|
return;
|
|
}
|
|
|
|
const operation = [
|
|
"UserByScreenName",
|
|
"UserTweets",
|
|
"TweetDetail",
|
|
"SearchTimeline",
|
|
"ListsManagementPageTimeline",
|
|
"ListLatestTweetsTimeline",
|
|
].find((name) => url.pathname.endsWith(`/${name}`));
|
|
|
|
if (!operation) {
|
|
fail(response, 501, "unsupported read operation");
|
|
return;
|
|
}
|
|
|
|
const expectedMethod = operation === "SearchTimeline" ? "POST" : "GET";
|
|
if (request.method !== expectedMethod) {
|
|
fail(response, 405, `expected ${expectedMethod}`, {
|
|
allow: expectedMethod,
|
|
});
|
|
return;
|
|
}
|
|
const profileName = request.headers["x-profile-name"];
|
|
if (profileName !== "e2e" && profileName !== "alternate") {
|
|
fail(response, 403, "invalid profile");
|
|
return;
|
|
}
|
|
|
|
let variables;
|
|
try {
|
|
variables = variablesFrom(url);
|
|
} catch {
|
|
fail(response, 400, "malformed variables");
|
|
return;
|
|
}
|
|
|
|
if (operation === "UserByScreenName") {
|
|
if (typeof variables.screen_name !== "string" || variables.screen_name.length === 0) {
|
|
fail(response, 400, "screen_name is required");
|
|
return;
|
|
}
|
|
send(response, {
|
|
data: {
|
|
user: {
|
|
result: {
|
|
rest_id: "42",
|
|
legacy: { screen_name: variables.screen_name, name: "Yuta" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (operation === "UserTweets") {
|
|
const expectedCursor = variables.cursor === undefined || variables.cursor === "user-next";
|
|
if (variables.userId !== "42" || variables.count !== 20 || !expectedCursor) {
|
|
fail(response, 400, "invalid user timeline variables");
|
|
return;
|
|
}
|
|
const secondPage = variables.cursor === "user-next";
|
|
const firstPageText = profileName === "alternate" ? "alternate profile page 1" : "user page 1";
|
|
const entries = secondPage
|
|
? [tweet("1010", "user page 2")]
|
|
: [
|
|
tweet("1001", firstPageText, "yuta", {
|
|
conversationId: "1000",
|
|
}),
|
|
cursor("user-next"),
|
|
];
|
|
send(response, {
|
|
data: {
|
|
user: { result: { timeline: { timeline: timeline(entries) } } },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (operation === "ListsManagementPageTimeline") {
|
|
if (variables.count !== 100) {
|
|
fail(response, 400, "invalid lists management variables");
|
|
return;
|
|
}
|
|
const features = url.searchParams.get("features");
|
|
try {
|
|
if (!features || !isRecord(JSON.parse(features))) throw new Error();
|
|
} catch {
|
|
fail(response, 400, "invalid lists management feature lock");
|
|
return;
|
|
}
|
|
send(response, {
|
|
data: {
|
|
viewer: {
|
|
list_management_timeline: {
|
|
timeline: {
|
|
instructions: [
|
|
{
|
|
entries: [
|
|
{
|
|
content: {
|
|
clientEventInfo: {
|
|
component: "suggest_owned_subscribed_list",
|
|
},
|
|
items: [
|
|
{
|
|
item: {
|
|
itemContent: {
|
|
list: {
|
|
id_str: "1234567890",
|
|
name:
|
|
profileName === "alternate" ? "Alternate List" : "My E2E List",
|
|
description: "Accounts selected for testing",
|
|
member_count: 10,
|
|
mode: "Public",
|
|
created_at: 1_704_067_200_000,
|
|
user_results: {
|
|
result: {
|
|
rest_id: "42",
|
|
core: {
|
|
screen_name: "yuta",
|
|
name: "Yuta",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
errors: [{ message: "optional banner field failed" }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (operation === "ListLatestTweetsTimeline") {
|
|
const expectedCursor = variables.cursor === undefined || variables.cursor === "list-next";
|
|
if (
|
|
typeof variables.listId !== "string" ||
|
|
!/^\d+$/.test(variables.listId) ||
|
|
variables.count !== 20 ||
|
|
!expectedCursor
|
|
) {
|
|
fail(response, 400, "invalid list timeline variables");
|
|
return;
|
|
}
|
|
const secondPage = variables.cursor === "list-next";
|
|
const entries = secondPage
|
|
? [tweet("3010", "list page 2", "listed")]
|
|
: [tweet("3001", "list page 1", "listed"), cursor("list-next")];
|
|
send(response, {
|
|
data: {
|
|
list: { tweets_timeline: { timeline: timeline(entries) } },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (operation === "TweetDetail") {
|
|
const focalTweetId = variables.focalTweetId;
|
|
if (typeof focalTweetId !== "string" || !/^\d+$/.test(focalTweetId)) {
|
|
fail(response, 400, "invalid focalTweetId");
|
|
return;
|
|
}
|
|
const features = url.searchParams.get("features");
|
|
const fieldToggles = url.searchParams.get("fieldToggles");
|
|
try {
|
|
if (!features || !isRecord(JSON.parse(features))) throw new Error();
|
|
if (!fieldToggles || !isRecord(JSON.parse(fieldToggles))) {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
fail(response, 400, "invalid TweetDetail feature locks");
|
|
return;
|
|
}
|
|
|
|
const targetRoot = rootByTarget.get(focalTweetId);
|
|
if (targetRoot && variables.cursor === undefined) {
|
|
const noReplies = focalTweetId === "3001";
|
|
const text = noReplies
|
|
? "no replies"
|
|
: focalTweetId === "1001"
|
|
? "user page 1"
|
|
: "retry focal";
|
|
const result = tweetResult(focalTweetId, text, "focus", {
|
|
conversationId: targetRoot,
|
|
inReplyTo: targetRoot,
|
|
replyCount: noReplies ? 0 : 1,
|
|
});
|
|
send(response, {
|
|
data: {
|
|
tweetResult: { result },
|
|
threaded_conversation_with_injections_v2: {
|
|
instructions: [
|
|
{
|
|
entries: [
|
|
tweet(focalTweetId, text, "focus", {
|
|
conversationId: targetRoot,
|
|
inReplyTo: targetRoot,
|
|
replyCount: noReplies ? 0 : 1,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const targetId = targetByRoot.get(focalTweetId);
|
|
if (!targetId) {
|
|
fail(response, 400, "unsupported TweetDetail fixture");
|
|
return;
|
|
}
|
|
const expectedCursor = `thread-next:${focalTweetId}`;
|
|
if (variables.cursor !== undefined && variables.cursor !== expectedCursor) {
|
|
fail(response, 400, "invalid thread cursor");
|
|
return;
|
|
}
|
|
if (variables.cursor && focalTweetId === "3000") {
|
|
fail(response, 500, "zero-reply thread must not request a continuation");
|
|
return;
|
|
}
|
|
if (variables.cursor && focalTweetId === "2000" && !failedThreadRoots.has(focalTweetId)) {
|
|
failedThreadRoots.add(focalTweetId);
|
|
fail(response, 503, "transient thread fixture failure");
|
|
return;
|
|
}
|
|
|
|
const entries = variables.cursor
|
|
? [
|
|
tweet(`${focalTweetId}3`, "thread page 2", "reply2", {
|
|
conversationId: focalTweetId,
|
|
inReplyTo: targetId,
|
|
}),
|
|
]
|
|
: focalTweetId === "3000"
|
|
? [
|
|
tweet(focalTweetId, "thread root", "root", {
|
|
conversationId: focalTweetId,
|
|
}),
|
|
tweet(targetId, "no replies", "focus", {
|
|
conversationId: focalTweetId,
|
|
inReplyTo: focalTweetId,
|
|
replyCount: 0,
|
|
}),
|
|
cursor(expectedCursor),
|
|
]
|
|
: [
|
|
tweet(focalTweetId, "thread root", "root", {
|
|
conversationId: focalTweetId,
|
|
}),
|
|
tweet(targetId, focalTweetId === "1000" ? "user page 1" : "retry focal", "focus", {
|
|
conversationId: focalTweetId,
|
|
inReplyTo: focalTweetId,
|
|
}),
|
|
tweet(`${focalTweetId}2`, "thread page 1", "reply1", {
|
|
conversationId: focalTweetId,
|
|
inReplyTo: targetId,
|
|
}),
|
|
cursor(expectedCursor),
|
|
];
|
|
send(response, {
|
|
data: {
|
|
tweetResult: {
|
|
result: tweetResult(focalTweetId, "thread root", "root", {
|
|
conversationId: focalTweetId,
|
|
}),
|
|
},
|
|
threaded_conversation_with_injections_v2: {
|
|
instructions: [{ entries }],
|
|
},
|
|
},
|
|
});
|
|
if (variables.cursor && focalTweetId === "2000") {
|
|
failedThreadRoots.delete(focalTweetId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const contentType = request.headers["content-type"] ?? "";
|
|
if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
|
|
fail(response, 400, "SearchTimeline requires JSON");
|
|
return;
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await jsonBodyFrom(request);
|
|
} catch {
|
|
fail(response, 400, "malformed JSON body");
|
|
return;
|
|
}
|
|
|
|
const rawQuery = variables.rawQuery;
|
|
const expectedCursor =
|
|
variables.cursor === undefined || variables.cursor === `search-next:${rawQuery}`;
|
|
if (
|
|
typeof rawQuery !== "string" ||
|
|
rawQuery.length === 0 ||
|
|
variables.count !== 20 ||
|
|
variables.querySource !== "typed_query" ||
|
|
!["Top", "Latest"].includes(variables.product) ||
|
|
!expectedCursor ||
|
|
!isRecord(body.features) ||
|
|
typeof body.queryId !== "string" ||
|
|
body.queryId.length === 0 ||
|
|
Object.hasOwn(body, "variables")
|
|
) {
|
|
fail(response, 400, "invalid search request");
|
|
return;
|
|
}
|
|
|
|
const secondPage = variables.cursor !== undefined;
|
|
if (secondPage && rawQuery.startsWith("retry-") && !failedRetryQueries.has(rawQuery)) {
|
|
failedRetryQueries.add(rawQuery);
|
|
fail(response, 503, "transient fixture failure");
|
|
return;
|
|
}
|
|
if (secondPage && rawQuery.startsWith("slow-")) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
|
}
|
|
|
|
const follows = rawQuery.includes("filter:follows") ? "follows" : "all";
|
|
const label = `${profileName === "alternate" ? "alternate · " : ""}${variables.product} · ${follows}`;
|
|
const id = encodeURIComponent(rawQuery);
|
|
const entries = secondPage
|
|
? [tweet(`s-${id}-2`, `${label} page 2`)]
|
|
: [tweet(`s-${id}-1`, `${label} page 1`), cursor(`search-next:${rawQuery}`)];
|
|
|
|
send(response, {
|
|
data: {
|
|
search_by_raw_query: {
|
|
search_timeline: { timeline: timeline(entries) },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1");
|