fix: park the growth page on Reddit's origin before fetching
Growth could never post. Its page is opened with newPage() and was never navigated, so it sat on about:blank, whose opaque origin cannot fetch Reddit at all — production logged "Failed to read the Reddit session modhash: TypeError: Failed to fetch" on every attempt. Confirmed against real network: the same fetch throws from about:blank and succeeds from https://old.reddit.com. The transport now parks the page on that origin before any request, and re-parks it if the page wanders elsewhere. Being same-origin is also what sends the session cookies, so the authenticated info.json lookup moved to the same host rather than www; the signed-out visibility probe stays on www because it runs from Node, where CORS does not apply. The unit tests could not catch this: they stub the transport, and the earlier browser check navigated the page by hand in the harness — the production path was the one place that never did. The page fake now starts on about:blank like the real thing and asserts the navigation. Also stop a transport failure from invalidating the account session. A modhash lookup that could not reach Reddit returned session-invalid, which calls auth.invalidate() and so would disrupt outreach and chat for that account over a network blip. Only Reddit actually reporting a signed-out session (HTTP 401/403, or a response with no modhash) does that now; anything else is retryable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ac6ad22ad
commit
f36e93d5be
2 files changed
+248
-42
No files matched your search
@@ -149,6 +149,58 @@ describe('RedditCommentClient.postComment', () => {
|
||||
expect(transport.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('when the modhash lookup cannot reach Reddit', () => {
|
||||
// `session-invalid` invalidates the account's shared auth context, which
|
||||
// would disrupt outreach and chat too. Only Reddit actually saying we are
|
||||
// signed out may do that; a transport failure must stay retryable.
|
||||
it('reports a thrown transport error as retryable, not signed out', async () => {
|
||||
const transport = fakeTransport({
|
||||
get: () => { throw new TypeError('Failed to fetch'); },
|
||||
});
|
||||
const client = new RedditCommentClient(transport, logger);
|
||||
|
||||
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result).toMatchObject({ reason: expect.stringContaining('Failed to fetch') });
|
||||
expect(transport.posts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports a 5xx identity check as retryable', async () => {
|
||||
const client = new RedditCommentClient(
|
||||
fakeTransport({ get: () => ({ status: 503, body: '' }) }),
|
||||
logger,
|
||||
);
|
||||
|
||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed');
|
||||
});
|
||||
|
||||
it.each([401, 403])('treats HTTP %i on the identity check as signed out', async (status) => {
|
||||
const client = new RedditCommentClient(
|
||||
fakeTransport({ get: () => ({ status, body: '' }) }),
|
||||
logger,
|
||||
);
|
||||
|
||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid');
|
||||
});
|
||||
|
||||
it('retries the lookup on the next attempt rather than caching the failure', async () => {
|
||||
let attempt = 0;
|
||||
const transport = fakeTransport({
|
||||
get: () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new TypeError('Failed to fetch');
|
||||
return MODHASH_OK;
|
||||
},
|
||||
post: () => successBody('t1_ok'),
|
||||
});
|
||||
const client = new RedditCommentClient(transport, logger);
|
||||
|
||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed');
|
||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('posted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reddit error codes', () => {
|
||||
it('maps RATELIMIT to an account-scoped wait and cools the shared scheduler', async () => {
|
||||
const extendCooldown = vi.fn();
|
||||
@@ -408,9 +460,86 @@ describe('RedditCommentClient.fetchPubliclyVisible', () => {
|
||||
});
|
||||
|
||||
describe('createBrowserWriteTransport', () => {
|
||||
/** Mirrors production: a freshly opened Playwright page is on about:blank. */
|
||||
function fakePage(initialUrl = 'about:blank') {
|
||||
const calls: unknown[] = [];
|
||||
const navigations: string[] = [];
|
||||
let current = initialUrl;
|
||||
return {
|
||||
calls,
|
||||
navigations,
|
||||
url: () => current,
|
||||
goto: async (target: string) => {
|
||||
navigations.push(target);
|
||||
current = target;
|
||||
return null;
|
||||
},
|
||||
evaluate: async <Result, Argument>(
|
||||
fn: (argument: Argument) => Promise<Result>,
|
||||
argument: Argument,
|
||||
): Promise<Result> => {
|
||||
calls.push(argument);
|
||||
const fetchStub = async (url: string, init?: { method?: string; body?: string }) => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ url, method: init?.method ?? 'GET', body: init?.body }),
|
||||
});
|
||||
const original = globalThis.fetch;
|
||||
(globalThis as { fetch: unknown }).fetch = fetchStub;
|
||||
try {
|
||||
return await fn(argument);
|
||||
} finally {
|
||||
(globalThis as { fetch: unknown }).fetch = original;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('parks the page on the Reddit origin before fetching from about:blank', async () => {
|
||||
const page = fakePage();
|
||||
const transport = createBrowserWriteTransport(page);
|
||||
|
||||
await transport.get('https://old.reddit.com/api/me.json');
|
||||
|
||||
// Without this a fetch from about:blank fails outright with "Failed to fetch",
|
||||
// and any other origin would withhold the session cookies.
|
||||
expect(page.navigations).toEqual(['https://old.reddit.com']);
|
||||
});
|
||||
|
||||
it('navigates once and reuses the origin for later requests', async () => {
|
||||
const page = fakePage();
|
||||
const transport = createBrowserWriteTransport(page);
|
||||
|
||||
await transport.get('https://old.reddit.com/api/me.json');
|
||||
await transport.postForm('https://old.reddit.com/api/comment', { text: 'hi' });
|
||||
await transport.get('https://old.reddit.com/api/info.json?id=t1_a');
|
||||
|
||||
expect(page.navigations).toEqual(['https://old.reddit.com']);
|
||||
});
|
||||
|
||||
it('re-parks the page if it wanders to another origin', async () => {
|
||||
const page = fakePage('https://www.reddit.com/r/cats/');
|
||||
const transport = createBrowserWriteTransport(page);
|
||||
|
||||
await transport.postForm('https://old.reddit.com/api/comment', { text: 'hi' });
|
||||
|
||||
// www is a different origin, so the cookies would not be sent.
|
||||
expect(page.navigations).toEqual(['https://old.reddit.com']);
|
||||
});
|
||||
|
||||
it('does not navigate when already on the write origin', async () => {
|
||||
const page = fakePage('https://old.reddit.com/r/cats/');
|
||||
const transport = createBrowserWriteTransport(page);
|
||||
|
||||
await transport.get('https://old.reddit.com/api/me.json');
|
||||
|
||||
expect(page.navigations).toEqual([]);
|
||||
});
|
||||
|
||||
it('runs both verbs inside the page so requests carry the profile session', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const page = {
|
||||
url: () => 'https://old.reddit.com/',
|
||||
goto: async () => null,
|
||||
evaluate: async <Result, Argument>(
|
||||
fn: (argument: Argument) => Promise<Result>,
|
||||
argument: Argument,
|
||||
|
||||
Reference in new issue
Block a user