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

223 lines
9.2 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
import { __test__ } from './search.js';
const {
parseCsvArg,
parseIntegerArg,
mapFilterValues,
decodeLinkedinRedirect,
looksLinkedInAuthWallText,
enrichJobDetails,
EXPERIENCE_LEVELS,
JOB_TYPES,
DATE_POSTED,
REMOTE_TYPES,
} = __test__;
const getSearchCommand = () => getRegistry().get('linkedin/search');
describe('linkedin parseCsvArg', () => {
it('returns empty array for empty / null / undefined', () => {
expect(parseCsvArg(undefined)).toEqual([]);
expect(parseCsvArg(null)).toEqual([]);
expect(parseCsvArg('')).toEqual([]);
});
it('splits and trims comma-separated values', () => {
expect(parseCsvArg('full-time, contract')).toEqual(['full-time', 'contract']);
expect(parseCsvArg(' a , b , , c ')).toEqual(['a', 'b', 'c']);
});
});
describe('linkedin mapFilterValues', () => {
it('maps known values to upstream codes and dedupes', () => {
expect(mapFilterValues('full-time, contract, full', JOB_TYPES, 'job_type')).toEqual(['F', 'C']);
expect(mapFilterValues('remote, hybrid', REMOTE_TYPES, 'remote')).toEqual(['2', '3']);
});
it('throws ArgumentError on unknown filter values (no silent drop)', () => {
expect(() => mapFilterValues('martian', JOB_TYPES, 'job_type')).toThrow(ArgumentError);
expect(() => mapFilterValues('full-time, ufo', JOB_TYPES, 'job_type')).toThrow(ArgumentError);
});
it('returns empty array for empty input', () => {
expect(mapFilterValues('', EXPERIENCE_LEVELS, 'experience_level')).toEqual([]);
expect(mapFilterValues(undefined, DATE_POSTED, 'date_posted')).toEqual([]);
});
});
describe('linkedin argument validation', () => {
it('rejects --limit outside 1..100 instead of silently clamping', () => {
expect(() => parseIntegerArg(0, '--limit', 10, 1, 100)).toThrow(ArgumentError);
expect(() => parseIntegerArg(101, '--limit', 10, 1, 100)).toThrow(ArgumentError);
expect(() => parseIntegerArg('10.5', '--limit', 10, 1, 100)).toThrow(ArgumentError);
});
it('rejects negative --start instead of silently clamping to zero', () => {
expect(() => parseIntegerArg(-1, '--start', 0, 0)).toThrow(ArgumentError);
expect(parseIntegerArg(undefined, '--start', 0, 0)).toBe(0);
expect(parseIntegerArg('25', '--start', 0, 0)).toBe(25);
});
it('validates command args before browser navigation', async () => {
const command = getSearchCommand();
const page = { goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() };
await expect(command.func(page, { query: 'engineer', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func(page, { query: 'engineer', start: -1 })).rejects.toBeInstanceOf(ArgumentError);
expect(page.goto).not.toHaveBeenCalled();
});
});
describe('linkedin auth wall detection', () => {
it('recognizes login/authwall signals', () => {
expect(looksLinkedInAuthWallText('https://www.linkedin.com/authwall?trk=guest Sign in to continue')).toBe(true);
expect(looksLinkedInAuthWallText('LinkedIn Login, Sign in')).toBe(true);
expect(looksLinkedInAuthWallText('About the job Senior infrastructure engineer')).toBe(false);
});
it('throws AuthRequiredError when search lands on a login wall', async () => {
const command = getSearchCommand();
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(true),
};
await expect(command.func(page, { query: 'engineer', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
});
});
describe('linkedin decodeLinkedinRedirect', () => {
it('extracts the underlying url from a /redir/redirect/ wrapper', () => {
const target = 'https://example.com/jobs/apply?id=42';
const wrapped = `https://www.linkedin.com/redir/redirect/?url=${encodeURIComponent(target)}&source=jobs`;
expect(decodeLinkedinRedirect(wrapped)).toBe(target);
});
it('returns the input unchanged for non-redirect urls', () => {
const direct = 'https://example.com/jobs/42/';
expect(decodeLinkedinRedirect(direct)).toBe(direct);
});
it('returns empty string for falsy input', () => {
expect(decodeLinkedinRedirect('')).toBe('');
expect(decodeLinkedinRedirect(null)).toBe('');
});
});
describe('linkedin enrichJobDetails (silent failure fix)', () => {
function makeFakePage({ evaluateResults = [], gotoFails = [], evaluateFails = [] } = {}) {
let evalCall = 0;
let gotoCall = 0;
return {
goto: vi.fn(async () => {
if (gotoFails[gotoCall++]) {
throw new Error(gotoFails[gotoCall - 1]);
}
}),
wait: vi.fn(async () => undefined),
evaluate: vi.fn(async () => {
const idx = evalCall++;
if (evaluateFails[idx]) throw new Error(evaluateFails[idx]);
return evaluateResults[idx];
}),
};
}
it('surfaces detail_error="no url" when row has no URL (instead of silent empty string)', async () => {
const page = makeFakePage();
const out = await enrichJobDetails(page, [
{ rank: 1, title: 'No URL Job', company: 'X', url: '' },
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
description: null,
apply_url: null,
detail_error: 'no url',
});
expect(page.goto).not.toHaveBeenCalled();
});
it('surfaces detail_error="fetch failed: ..." when goto throws (no silent swallow)', async () => {
const page = makeFakePage({ gotoFails: ['network down'] });
const out = await enrichJobDetails(page, [
{ rank: 1, title: 'Fetch Fail', company: 'X', url: 'https://www.linkedin.com/jobs/view/1' },
]);
expect(out).toHaveLength(1);
expect(out[0].description).toBeNull();
expect(out[0].apply_url).toBeNull();
expect(out[0].detail_error).toMatch(/^fetch failed: .*network down/);
});
it('surfaces detail_error="missing description" on empty description (signals upstream gap, not crash)', async () => {
const page = makeFakePage({
evaluateResults: [
false, // auth wall probe
undefined, // expand-show-more click result (ignored)
{ description: '', applyUrl: '' },
],
});
const out = await enrichJobDetails(page, [
{ rank: 1, title: 'Empty Desc', company: 'X', url: 'https://www.linkedin.com/jobs/view/2' },
]);
expect(out[0].description).toBeNull();
expect(out[0].apply_url).toBeNull();
expect(out[0].detail_error).toBe('missing description');
});
it('surfaces detail_error=null on a fully successful enrichment', async () => {
const page = makeFakePage({
evaluateResults: [
false, // auth wall probe
undefined,
{ description: ' An interesting role ', applyUrl: 'https://example.com/apply' },
],
});
const out = await enrichJobDetails(page, [
{ rank: 1, title: 'OK', company: 'X', url: 'https://www.linkedin.com/jobs/view/3' },
]);
expect(out[0]).toMatchObject({
description: 'An interesting role',
apply_url: 'https://example.com/apply',
detail_error: null,
});
});
it('processes multiple rows with mixed outcomes without aborting the batch', async () => {
const page = makeFakePage({
evaluateResults: [
// Row 1 (success)
false,
undefined,
{ description: 'Good', applyUrl: 'https://a.example/' },
// Row 3 (success — row 2 had no URL so didn't navigate)
false,
undefined,
{ description: 'Also good', applyUrl: 'https://b.example/' },
],
});
const out = await enrichJobDetails(page, [
{ rank: 1, title: 'A', company: 'X', url: 'https://www.linkedin.com/jobs/view/10' },
{ rank: 2, title: 'B', company: 'X', url: '' },
{ rank: 3, title: 'C', company: 'X', url: 'https://www.linkedin.com/jobs/view/30' },
]);
expect(out).toHaveLength(3);
expect(out[0].detail_error).toBeNull();
expect(out[1].detail_error).toBe('no url');
expect(out[2].detail_error).toBeNull();
// Goto was only called for rows with URL (1 and 3)
expect(page.goto).toHaveBeenCalledTimes(2);
});
it('throws AuthRequiredError on detail auth wall instead of burying it in detail_error', async () => {
const page = makeFakePage({ evaluateResults: [true] });
await expect(enrichJobDetails(page, [
{ rank: 1, title: 'Needs Auth', company: 'X', url: 'https://www.linkedin.com/jobs/view/4' },
])).rejects.toBeInstanceOf(AuthRequiredError);
});
});