Files
wehub-resource-sync 9b395f5cc3
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
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

217 lines
11 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 './people-search.js';
const {
parseLimit,
buildSearchUrl,
looksLinkedInAuthWall,
normalizeProfileUrl,
normalizePeopleRows,
parseNonNegativeCount,
extractionScript,
} = await import('./people-search.js').then((m) => m.__test__);
function extractionResult(rows, counts = {}) {
return {
rows,
candidate_count: counts.candidate_count ?? rows.length,
person_entries_count: counts.person_entries_count ?? counts.candidate_count ?? rows.length,
resolved_count: counts.resolved_count ?? rows.length,
};
}
function makePage({
evaluateResult,
evaluateReject,
gotoReject,
cookies = [{ name: 'JSESSIONID', value: '"ajax:1234567890"' }],
} = {}) {
return {
goto: vi.fn().mockImplementation(() => gotoReject ? Promise.reject(gotoReject) : Promise.resolve(undefined)),
wait: vi.fn().mockResolvedValue(undefined),
getCookies: vi.fn().mockResolvedValue(cookies),
evaluate: vi.fn().mockImplementation(() => evaluateReject ? Promise.reject(evaluateReject) : Promise.resolve(evaluateResult)),
};
}
describe('linkedin people-search command', () => {
it('builds the canonical SSR search URL with encoded keywords', () => {
expect(buildSearchUrl('site reliability engineer'))
.toBe('https://www.linkedin.com/search/results/people/?keywords=site%20reliability%20engineer');
expect(buildSearchUrl('hello/world & stuff'))
.toBe('https://www.linkedin.com/search/results/people/?keywords=hello%2Fworld%20%26%20stuff');
});
it('validates --limit without silent clamping', () => {
expect(parseLimit(undefined)).toBe(5);
expect(parseLimit(1)).toBe(1);
expect(parseLimit(10)).toBe(10);
expect(() => parseLimit(0)).toThrow(ArgumentError);
expect(() => parseLimit(11)).toThrow(ArgumentError);
expect(() => parseLimit(-1)).toThrow(ArgumentError);
expect(() => parseLimit('abc')).toThrow(ArgumentError);
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
});
it('extraction script slices main.innerText by person-name boundaries', () => {
const s = extractionScript();
// Anchor enumeration finds /in/<handle>.
expect(s).toContain('a[href*="/in/"]');
expect(s).toContain('\\/in\\/([^/?#]+)');
// Text-slice approach: split main.innerText and locate names.
expect(s).toContain('main.innerText');
expect(s).toContain('lines.findIndex');
// Mutual-connection anchors are filtered out via the skip()
// predicate on the name-line match.
expect(s).toContain('mutual connection');
// Names dedup'd by handle.
expect(s).toContain('seenHandles');
// Aria-hidden span as canonical name source.
expect(s).toContain('span[aria-hidden="true"]');
// Only operates on the people-search page.
expect(s).toContain('search\\/results\\/people');
expect(s).toContain('candidate_count');
expect(s).toContain('resolved_count');
});
it('normalizes only stable LinkedIn profile identities', () => {
expect(normalizeProfileUrl('https://www.linkedin.com/in/alice-engineer/?mini=true'))
.toBe('https://www.linkedin.com/in/alice-engineer/');
expect(normalizeProfileUrl('https://linkedin.com/in/bob-builder')).toBe('https://www.linkedin.com/in/bob-builder/');
expect(normalizeProfileUrl('https://evil-linkedin.com/in/bob-builder')).toBe('');
expect(normalizeProfileUrl('http://www.linkedin.com/in/bob-builder')).toBe('');
expect(normalizeProfileUrl('https://www.linkedin.com/company/opencli')).toBe('');
});
it('detects LinkedIn auth-wall URLs separately from CUL redirects', () => {
expect(looksLinkedInAuthWall('https://www.linkedin.com/authwall Sign in to continue')).toBe(true);
expect(looksLinkedInAuthWall('https://www.linkedin.com/checkpoint/challenge security verification required')).toBe(true);
expect(looksLinkedInAuthWall('https://www.linkedin.com/feed/')).toBe(false);
});
it('rejects malformed extraction rows instead of fabricating success rows', () => {
expect(() => normalizePeopleRows({})).toThrow(CommandExecutionError);
expect(() => normalizePeopleRows([null])).toThrow(CommandExecutionError);
expect(() => normalizePeopleRows([{ name: 'No URL', headline: 'h', location: 'l', profile_url: '' }]))
.toThrow(CommandExecutionError);
expect(() => normalizePeopleRows([{ name: '', headline: 'h', location: 'l', profile_url: 'https://www.linkedin.com/in/no-name/' }]))
.toThrow(CommandExecutionError);
});
it('validates extraction evidence counters', () => {
expect(parseNonNegativeCount(0, 'candidate_count')).toBe(0);
expect(parseNonNegativeCount(2, 'candidate_count')).toBe(2);
expect(() => parseNonNegativeCount(undefined, 'candidate_count')).toThrow(CommandExecutionError);
expect(() => parseNonNegativeCount(-1, 'candidate_count')).toThrow(CommandExecutionError);
expect(() => parseNonNegativeCount(1.2, 'candidate_count')).toThrow(CommandExecutionError);
});
it('returns ranked rows when the page yields people', async () => {
const cmd = getRegistry().get('linkedin/people-search');
expect(cmd?.func).toBeTypeOf('function');
const page = makePage({
evaluateResult: extractionResult([
{ name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
{ name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
]),
});
const result = await cmd.func(page, { keywords: 'reinforcement learning', limit: 5 });
expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/search/results/people/?keywords=reinforcement%20learning');
expect(result).toEqual([
{ rank: 1, name: 'Alice Engineer', headline: 'Staff SWE at Acme', location: 'Berlin', profile_url: 'https://www.linkedin.com/in/alice-engineer/' },
{ rank: 2, name: 'Bob Builder', headline: 'CTO at Globex', location: 'Remote', profile_url: 'https://www.linkedin.com/in/bob-builder/' },
]);
});
it('slices to --limit when more rows are extracted than requested', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({
evaluateResult: extractionResult(Array.from({ length: 8 }, (_, i) => ({
name: `Person ${i}`, headline: 'h', location: 'l', profile_url: `https://www.linkedin.com/in/p${i}/`,
}))),
});
const result = await cmd.func(page, { keywords: 'x', limit: 3 });
expect(result).toHaveLength(3);
expect(result.map((r) => r.rank)).toEqual([1, 2, 3]);
});
it('throws AuthRequiredError when JSESSIONID cookie is missing', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ cookies: [], evaluateResult: extractionResult([]) });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
});
it('treats malformed cookie lookup results as CommandExecutionError', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ cookies: null, evaluateResult: extractionResult([]) });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('treats LinkedIn redirect away from search page as a CUL-flavoured CommandExecutionError', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/' } });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('treats LinkedIn auth-wall redirects as AuthRequiredError', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateResult: { error: 'not on people search page', url: 'https://www.linkedin.com/authwall?trk=people_search' } });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
});
it('wraps browser extraction exceptions as CommandExecutionError', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateReject: new SyntaxError('Unexpected token <') });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('wraps browser navigation exceptions as CommandExecutionError', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ gotoReject: new Error('navigation failed') });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws EmptyResultError when the page rendered zero rows', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateResult: extractionResult([]) });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('treats profile candidates without stable parsed rows as parser drift', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({
evaluateResult: extractionResult([], {
candidate_count: 1,
person_entries_count: 1,
resolved_count: 0,
}),
});
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('treats missing rows array as parser drift, not empty results', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateResult: {} });
await expect(cmd.func(page, { keywords: 'x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('rejects empty keywords with ArgumentError before navigation', async () => {
const cmd = getRegistry().get('linkedin/people-search');
const page = makePage({ evaluateResult: extractionResult([]) });
await expect(cmd.func(page, { keywords: ' ', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
it('registers with the expected columns and arg shape', () => {
const cmd = getRegistry().get('linkedin/people-search');
expect(cmd?.columns).toEqual(['rank', 'name', 'headline', 'location', 'profile_url']);
expect(cmd?.access).toBe('read');
expect(cmd?.browser).toBe(true);
const keywordsArg = cmd?.args?.find((a) => a.name === 'keywords');
expect(keywordsArg?.positional).toBe(true);
expect(keywordsArg?.required).toBe(true);
});
});