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, postOverrides = {}) { return [ { data: { children: [{ data: { title: 'Post title', selftext: '', author: 'op', score: 10, is_self: true, ...postOverrides, }, }], }, }, { 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('uses an ephemeral Reddit site tab by default', () => { expect(command?.browser).toBe(true); expect(command?.siteSession).toBeUndefined(); expect(command?.columns).toEqual([ 'type', 'author', 'score', 'text', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls', ]); }); it('embeds extractRedditMedia in the browser-evaluated source and applies it to the POST row', async () => { const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } }); await command.func(page, { 'post-id': 'abc123', limit: 5 }); const src = page.evaluate.mock.calls[0][0]; expect(src).toContain('function extractRedditMedia'); expect(src).toContain('var postMedia = extractRedditMedia(post)'); }); 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', post_hint: 'image', url_overridden_by_dest: 'https://i.redd.it/a.jpg', preview_image_url: 'https://preview.redd.it/a.jpg?width=640', gallery_urls: [] }, { type: 'L0', author: 'bob', score: 5, text: 'Comment', post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [] }, ], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] }, }); const result = await command.func(page, { 'post-id': 'abc123', limit: 5 }); // perf: read 不再显式导航首页——框架 navigateBefore(domain=reddit.com)已把页面带到 reddit origin expect(page.goto).not.toHaveBeenCalledWith('https://www.reddit.com'); expect(result).toEqual([ { type: 'POST', author: 'alice', score: 10, text: 'Title', post_hint: 'image', url_overridden_by_dest: 'https://i.redd.it/a.jpg', preview_image_url: 'https://preview.redd.it/a.jpg?width=640', gallery_urls: [] }, { type: 'L0', author: 'bob', score: 5, text: 'Comment', post_hint: '', url_overridden_by_dest: '', preview_image_url: '', gallery_urls: [] }, ]); }); 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('extracts post media columns from the Reddit listing payload in order', async () => { const fetchMock = vi.fn(async (url) => { if (String(url).startsWith('/comments/')) { return jsonResponse(redditPostEnvelope([], { is_self: false, post_hint: 'image', url: 'https://reddit.com/r/pics/comments/abc123/media_post/', url_overridden_by_dest: 'https://i.redd.it/a&b.jpg', preview: { images: [{ source: { url: 'https://preview.redd.it/source.jpg?width=960&format=pjpg' }, }], }, gallery_data: { items: [ { media_id: 'second' }, { media_id: 'first' }, { media_id: 'animated' }, ], }, media_metadata: { first: { s: { u: 'https://preview.redd.it/first.jpg?width=640&format=pjpg' } }, second: { s: { u: 'https://preview.redd.it/second.jpg?width=640&format=pjpg' } }, animated: { s: { gif: 'https://preview.redd.it/animated.gif?format=mp4&s=123' } }, }, })); } throw new Error(`unexpected URL ${url}`); }); const page = makeRuntimePage(fetchMock); const result = await command.func(page, { 'post-id': 'abc123' }); expect(result[0]).toMatchObject({ type: 'POST', post_hint: 'image', url_overridden_by_dest: 'https://i.redd.it/a&b.jpg', preview_image_url: 'https://preview.redd.it/source.jpg?width=960&format=pjpg', gallery_urls: [ 'https://preview.redd.it/second.jpg?width=640&format=pjpg', 'https://preview.redd.it/first.jpg?width=640&format=pjpg', 'https://preview.redd.it/animated.gif?format=mp4&s=123', ], }); }); 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'"); }); }); });