Files
jackwener__opencli/clis/reddit/read.test.js
jakevin c3912d8e5c feat(reddit/read): --expand-more via /api/morechildren + 7-kind typed errors (#1492)
* feat(reddit/read): add --expand-more via /api/morechildren + 7-kind discriminated union

PR B of the rdt-cli parity follow-up (after PR #1491, see #1481 thread).
Closes the second-largest gap: Reddit's "[+N more replies]" stubs were
opaque markers in the comment tree. With --expand-more, the adapter
follows them by POST-ing the t1 ids to /api/morechildren.json, then
re-threads the returned things back into the tree by parent_id before
walking it.

New args:

- `--expand-more` (bool, default false) — turn on stub expansion.
- `--expand-rounds <N>` (int, default 2, range [1, 5]) — Reddit returns
  fresh "more" stubs at the expansion depth boundary, so up to N rounds
  are run. Strictly validated via `parseExpandRounds` — out-of-range
  raises ArgumentError BEFORE `page.goto`, no silent clamp.

Boy-Scout: the in-browser script now returns a 7-kind discriminated
union instead of a flat row array (matching the PR #1428 / #1491
sediment). Each kind maps 1:1 to a typed error on the Node side:

  - `inaccessible` → EmptyResultError
      401/403/404 on /comments/<id>.json (post-specific access, not
      session-level auth — applies the PR #1491 review-side sediment
      "inaccessible-resource vs session-auth").
  - `auth`         → AuthRequiredError
      401/403 on /api/morechildren (expand-write endpoints often demand
      a logged-in session even when the read endpoint is anonymous).
  - `http`         → CommandExecutionError
  - `malformed`    → CommandExecutionError
      200 with unexpected envelope shape — schema drift, not empty.
  - `parser-drift` → CommandExecutionError
      tree had t1 entries but the walker produced no rows (PR #1491
      review-side sediment "post-construction 0 rows + pre-walk
      non-empty = parser drift, not legitimate empty").
  - `expand-failed`→ CommandExecutionError
      /api/morechildren returned a non-empty json.errors array.
  - `ok`           → returns rows[].

Intermediate keys (kind / detail / httpStatus / where / rows /
expandMeta) deliberately avoid the declared columns (type / author /
score / text) per the PR #1329 silent-column-drop sediment.

Tests:
  clis/reddit/read.test.js — 11 tests
    - Adapter shape (browser / siteSession / columns / args)
    - --expand-more / --expand-rounds present with correct types/defaults
    - parseExpandRounds default / range / non-integer rejection
    - Pre-navigation validation (bad --expand-rounds doesn't reach goto)
    - kind=ok happy path (POST + L0 rows)
    - 6-kind error → typed error mapping
    - Unknown envelope shape → CommandExecutionError
    - Evaluate script embeds expandMore/expandRounds/sort/limit literals
    - Evaluate script contains /api/morechildren POST scaffolding
    - Evaluate script never names declared columns as intermediate keys

Full reddit suite 48/48; full project 3402/3402.

Audits: typed-error-lint 189/189 (0 new), silent-column-drop 103/103
(0 new). Manifest 815 → 815 (existing read entry gets 2 new args).

Existing --limit / --depth / --replies / --max-length keep their
original Math.max-style behaviour (grandfathered in the baseline);
only the new --expand-rounds flag fails fast per the typed-errors
standard.

Refs: https://github.com/jackwener/rdt-cli (browse.read --expand-more)

* fix(reddit): preserve expanded comment tree order

* fix(reddit): fail on partial morechildren expansion
2026-05-13 18:13:11 +08:00

336 lines
14 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { normalizeRedditPostId, parseExpandRounds } from './read.js';
import './read.js';
function makePage(result) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(result),
};
}
function redditPostEnvelope(children) {
return [
{
data: {
children: [{
data: {
title: 'Post title',
selftext: '',
author: 'op',
score: 10,
is_self: true,
},
}],
},
},
{ data: { children } },
];
}
function commentThing(id, body, parent = 't3_abc123', score = 1) {
return {
kind: 't1',
data: {
id,
name: `t1_${id}`,
parent_id: parent,
author: id,
score,
body,
replies: '',
},
};
}
function moreThing(id, children, parent = 't3_abc123', count = children.length) {
return {
kind: 'more',
data: { id, parent_id: parent, children, count },
};
}
function jsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(payload),
};
}
function makeRuntimePage(fetchImpl) {
return {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(async (script) => {
const previousFetch = globalThis.fetch;
globalThis.fetch = fetchImpl;
try {
return await eval(script);
} finally {
globalThis.fetch = previousFetch;
}
}),
};
}
describe('reddit read adapter', () => {
const command = getRegistry().get('reddit/read');
it('opts into the Reddit persistent site session', () => {
expect(command?.browser).toBe(true);
expect(command?.siteSession).toBe('persistent');
expect(command?.columns).toEqual(['type', 'author', 'score', 'text']);
});
it('exposes the new --expand-more / --expand-rounds args', () => {
const argNames = command.args.map((a) => a.name);
expect(argNames).toContain('expand-more');
expect(argNames).toContain('expand-rounds');
const expandMore = command.args.find((a) => a.name === 'expand-more');
expect(expandMore.type).toBe('bool');
expect(expandMore.default).toBe(false);
const rounds = command.args.find((a) => a.name === 'expand-rounds');
expect(rounds.type).toBe('int');
expect(rounds.default).toBe(2);
});
describe('normalizeRedditPostId', () => {
it('accepts bare ids, t3 fullnames, and exact reddit post URLs', () => {
expect(normalizeRedditPostId('1AbC23')).toBe('1abc23');
expect(normalizeRedditPostId('t3_1AbC23')).toBe('1abc23');
expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/?sort=top')).toBe('1abc23');
expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/?context=3')).toBe('1abc23');
expect(normalizeRedditPostId('https://old.reddit.com/comments/1abc23/title_slug/')).toBe('1abc23');
});
it('rejects invalid or structurally loose post identities before navigation', () => {
for (const bad of [
'',
't1_okf3s7u',
'https://reddit.com.evil.com/r/opencli/comments/1abc23/title_slug/',
'http://www.reddit.com/r/opencli/comments/1abc23/title_slug/',
'https://www.reddit.com/r/opencli/comments/',
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/evil',
'not/a/post',
]) {
expect(() => normalizeRedditPostId(bad)).toThrow(ArgumentError);
}
});
});
describe('parseExpandRounds', () => {
it('returns the default for absent input but throws on out-of-range / non-integer', () => {
expect(parseExpandRounds(undefined)).toBe(2);
expect(parseExpandRounds(null)).toBe(2);
expect(parseExpandRounds('')).toBe(2);
expect(parseExpandRounds(1)).toBe(1);
expect(parseExpandRounds(5)).toBe(5);
for (const bad of [0, -1, 6, 1.5, NaN, 'abc']) {
expect(() => parseExpandRounds(bad)).toThrow(ArgumentError);
}
});
});
it('rejects a bad --expand-rounds BEFORE navigating', async () => {
const page = makePage({ kind: 'ok', rows: [] });
await expect(command.func(page, { 'post-id': 'abc123', 'expand-rounds': 99 }))
.rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});
it('rejects a bad post identity BEFORE navigating', async () => {
const page = makePage({ kind: 'ok', rows: [] });
await expect(command.func(page, { 'post-id': 'https://evil.test/r/x/comments/abc/title/' }))
.rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
expect(page.evaluate).not.toHaveBeenCalled();
});
it('returns rows when the evaluate script reports kind=ok', async () => {
const page = makePage({
kind: 'ok',
rows: [
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
],
expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] },
});
const result = await command.func(page, { 'post-id': 'abc123', limit: 5 });
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
expect(result).toEqual([
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
]);
});
it('maps the five failure kinds to the right typed errors', async () => {
await expect(command.func(makePage({ kind: 'inaccessible', detail: 'post 403' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(EmptyResultError);
await expect(command.func(makePage({ kind: 'auth', detail: 'morechildren 401' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(AuthRequiredError);
await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/comments/abc.json' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ kind: 'malformed', detail: 'no comment listing' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ kind: 'parser-drift', detail: 'walker drift' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage({ kind: 'expand-failed', detail: 'morechildren errors' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError on an unknown envelope shape (no kind)', async () => {
await expect(command.func(makePage({ random: 'stuff' }), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
await expect(command.func(makePage(null), { 'post-id': 'abc123' }))
.rejects.toBeInstanceOf(CommandExecutionError);
});
it('embeds expandMore=false by default and inlines flags into the evaluate script', async () => {
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
await command.func(page, { 'post-id': 'xyz', sort: 'top', limit: 3 });
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain('var expandMore = false');
expect(script).toContain('var expandRounds = 2');
expect(script).toContain('var sort = "top"');
expect(script).toContain('var limit = 3');
expect(script).toContain('var postId = "xyz"');
});
it('embeds expandMore=true and the requested expandRounds when --expand-more is on', async () => {
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 3, fetched: 12, capped: true, errors: [] } });
await command.func(page, { 'post-id': 'xyz', 'expand-more': true, 'expand-rounds': 3 });
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain('var expandMore = true');
expect(script).toContain('var expandRounds = 3');
// The /api/morechildren request body construction must be present in
// the evaluate script (round-trips the link_id + children CSV).
expect(script).toContain("'/api/morechildren'");
expect(script).toContain("'api_type=json'");
expect(script).toContain("encodeURIComponent(linkFullname)");
expect(script).toContain("encodeURIComponent(batch.join(','))");
});
it('normalizes a full reddit URL before building the browser script', async () => {
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
await command.func(page, { 'post-id': 'https://www.reddit.com/r/python/comments/1abc23/title_slug/' });
const script = page.evaluate.mock.calls[0][0];
expect(script).toContain('var postId = "1abc23"');
expect(script).not.toContain('postIdRaw.match');
});
it('expands morechildren in the original tree position instead of appending to the parent', async () => {
const fetchMock = vi.fn(async (url) => {
if (String(url).startsWith('/comments/')) {
return jsonResponse(redditPostEnvelope([
commentThing('a', 'A'),
moreThing('more_top', ['b', 'c']),
commentThing('d', 'D'),
]));
}
if (String(url) === '/api/morechildren') {
return jsonResponse({
json: {
errors: [],
data: { things: [commentThing('b', 'B'), commentThing('c', 'C')] },
},
});
}
throw new Error(`unexpected URL ${url}`);
});
const page = makeRuntimePage(fetchMock);
const result = await command.func(page, {
'post-id': 'abc123',
'expand-more': true,
limit: 10,
replies: 10,
});
expect(result.map((row) => row.author)).toEqual(['op', 'a', 'b', 'c', 'd']);
expect(fetchMock).toHaveBeenCalledWith(
'/api/morechildren',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('link_id=t3_abc123'),
}),
);
});
it('fails expand-more when Reddit returns a child that cannot be placed in the requested tree', async () => {
const fetchMock = vi.fn(async (url) => {
if (String(url).startsWith('/comments/')) {
return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b'])]));
}
if (String(url) === '/api/morechildren') {
return jsonResponse({
json: {
errors: [],
data: { things: [commentThing('b', 'B', 't3_other')] },
},
});
}
throw new Error(`unexpected URL ${url}`);
});
const page = makeRuntimePage(fetchMock);
await expect(command.func(page, {
'post-id': 'abc123',
'expand-more': true,
})).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails expand-more when Reddit omits a requested child instead of silently dropping the stub', async () => {
const fetchMock = vi.fn(async (url) => {
if (String(url).startsWith('/comments/')) {
return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b', 'c'])]));
}
if (String(url) === '/api/morechildren') {
return jsonResponse({
json: {
errors: [],
data: { things: [commentThing('b', 'B')] },
},
});
}
throw new Error(`unexpected URL ${url}`);
});
const page = makeRuntimePage(fetchMock);
await expect(command.func(page, {
'post-id': 'abc123',
'expand-more': true,
})).rejects.toBeInstanceOf(CommandExecutionError);
});
it('uses 5-kind discriminated union keys that DO NOT collide with declared columns', () => {
// Read the evaluate template once to assert the intermediate keys we
// return on the browser side never name any of `type` / `author` /
// `score` / `text` (the declared columns) — that pattern would
// trigger the silent-column-drop audit.
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
return command.func(page, { 'post-id': 'xyz' }).then(() => {
const script = page.evaluate.mock.calls[0][0];
// Each return shape uses kind / detail / httpStatus / where /
// rows / expandMeta. None overlap with the four declared
// columns. The walker IS allowed to push column-shaped row
// objects into `rows` — that's the final shape, not an
// intermediate one.
expect(script).toContain("kind: 'inaccessible'");
expect(script).toContain("kind: 'auth'");
expect(script).toContain("kind: 'http'");
expect(script).toContain("kind: 'malformed'");
expect(script).toContain("kind: 'parser-drift'");
expect(script).toContain("kind: 'expand-failed'");
expect(script).toContain("kind: 'ok'");
});
});
});