import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import { __test__ } from './feed.js'; function runExtract(html, limit = 10, url = 'https://www.facebook.com/') { const dom = new JSDOM(html, { url }); return Function('window', 'document', `return ${__test__.buildFeedExtractScript(limit)};`)(dom.window, dom.window.document); } function createPage(payload) { return { goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue(payload), }; } describe('facebook feed', () => { it('registers the feed command with the existing row contract', () => { const cmd = getRegistry().get('facebook/feed'); expect(cmd).toBeDefined(); expect(cmd.columns).toEqual(['index', 'author', 'content', 'likes', 'comments', 'shares']); }); it('extracts existing role=article feed rows', () => { const payload = runExtract(`

Alice Example

This is a normal Facebook feed post with enough text to extract.
All: 12 3 comments 2 shares
`); expect(payload.status).toBe('ok'); expect(payload.rows).toEqual([{ index: 1, author: 'Alice Example', content: 'This is a normal Facebook feed post with enough text to extract.', likes: '12', comments: '3', shares: '2', }]); }); it('falls back from empty article nodes to action-bounded feed containers', () => { const payload = runExtract(`

Bob Builder

Fallback post body from a Facebook feed card with empty article text.
Permalink All: 1.2K 4 comments 1 shares
`); expect(payload.status).toBe('ok'); expect(payload.rows).toEqual([{ index: 1, author: 'Bob Builder', content: 'Fallback post body from a Facebook feed card with empty article text.', likes: '1.2K', comments: '4', shares: '1', }]); }); it('does not turn suggestions or side chrome action buttons into feed rows', () => { const payload = runExtract(`
`); expect(payload.status).toBe('no_rows'); expect(payload.rows).toEqual([]); }); it('still considers bounded fallback rows when article nodes are suggestion chrome', () => { const payload = runExtract(`

People you may know

Suggested profile card with enough text to look article-like.

Dana Poster

Fallback feed post should still be extracted after suggestion articles are filtered.
Permalink
`, 1); expect(payload.status).toBe('ok'); expect(payload.rows).toEqual([{ index: 1, author: 'Dana Poster', content: 'Fallback feed post should still be extracted after suggestion articles are filtered.', likes: '-', comments: '-', shares: '-', }]); }); it('reports auth pages from the browser extractor', () => { const payload = runExtract('
Log in to Facebook
', 10, 'https://www.facebook.com/login/'); expect(payload.status).toBe('auth'); expect(payload.rows).toEqual([]); }); it('validates limit before browser navigation', async () => { const page = createPage({ status: 'ok', rows: [] }); await expect(__test__.command.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError); expect(page.goto).not.toHaveBeenCalled(); }); it('maps browser envelopes and returns extracted rows', async () => { const page = createPage({ session: 'site:facebook', data: { status: 'ok', rows: [{ index: 1, author: 'A', content: 'Body', likes: '-', comments: '-', shares: '-' }] } }); await expect(__test__.command.func(page, { limit: 1 })).resolves.toEqual([{ index: 1, author: 'A', content: 'Body', likes: '-', comments: '-', shares: '-', }]); }); it('maps auth, real empty, parser drift, and malformed payloads to typed errors', async () => { await expect(__test__.command.func(createPage({ status: 'auth', rows: [] }), { limit: 1 })) .rejects.toBeInstanceOf(AuthRequiredError); await expect(__test__.command.func(createPage({ status: 'empty', rows: [] }), { limit: 1 })) .rejects.toBeInstanceOf(EmptyResultError); await expect(__test__.command.func(createPage({ status: 'no_rows', rows: [], diagnostics: { articleCount: 1, fallbackActionCount: 2, mainTextLength: 500 } }), { limit: 1 })) .rejects.toBeInstanceOf(CommandExecutionError); await expect(__test__.command.func(createPage({ rows: null }), { limit: 1 })) .rejects.toBeInstanceOf(CommandExecutionError); }); // Modern Facebook feed (#2089): no [role="article"], no Like/Comment // aria-labels — each post is bounded by its "Actions for this post" menu. // NOTE: this fixture encodes the DOM shape described in the issue, not a // captured live sample, so live verification is still required. it('extracts modern feed posts anchored on the "Actions for this post" menu (#2089)', () => { const payload = runExtract(`

Carol Poster

A modern feed post with no role=article wrapper anywhere on it.
2h

Dave Danger

Second streamed post body that should also be extracted fine.
`); expect(payload.status).toBe('ok'); expect(payload.diagnostics.actionMenuCount).toBe(2); expect(payload.rows.map((r) => r.author)).toEqual(['Carol Poster', 'Dave Danger']); expect(payload.rows[0].content).toContain('modern feed post'); }); it('keeps a legitimate author name that contains a 4-digit run (#2089)', () => { const payload = runExtract(`

Class of 2024

Reunion planning post body long enough to be extracted correctly.

Someone Else

A second post so the container walk stops before the main landmark.
`); expect(payload.rows[0].author).toBe('Class of 2024'); }); it('does not emit the whole main landmark as one post on a single-post page (#2089)', () => { const payload = runExtract(`

Solo Poster

The only post on the page — the container must not climb to role=main.
`); expect(payload.rows).toHaveLength(1); expect(payload.rows[0].author).toBe('Solo Poster'); }); it('rejects a digit-bearing decoy author and hidden-char decoy text (#2089)', () => { const payload = runExtract(`

Real Human

​​​
Genuine post content that survives the anti-scrape decoy filtering.
1234567890123
`); expect(payload.status).toBe('ok'); expect(payload.rows).toHaveLength(1); expect(payload.rows[0].author).toBe('Real Human'); expect(payload.rows[0].content).not.toContain('1234567890123'); }); });