9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
403 lines
18 KiB
JavaScript
403 lines
18 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, 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'");
|
||
});
|
||
});
|
||
});
|