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:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 20:44:33 +01:00
1 parent 1ac6ad22ad
commit f36e93d5be
2 files changed
+248 -42

No files matched your search

+129
View File
@@ -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,