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

280 lines
12 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './paper.js';
import './citations.js';
import './recommendations.js';
import './search.js';
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.SEMANTIC_SCHOLAR_API_KEY;
});
describe('semanticscholar adapter registry contracts', () => {
it('declares paper columns so paperId round-trips into citations and recommendations', () => {
const paper = getRegistry().get('semanticscholar/paper');
const citations = getRegistry().get('semanticscholar/citations');
const recommendations = getRegistry().get('semanticscholar/recommendations');
expect(paper).toBeDefined();
expect(citations).toBeDefined();
expect(recommendations).toBeDefined();
expect(paper.columns).toContain('paperId');
expect(citations.columns).toContain('paperId');
expect(recommendations.columns).toContain('paperId');
});
it('surfaces the unique Semantic Scholar fields on paper detail', () => {
const paper = getRegistry().get('semanticscholar/paper');
expect(paper.columns).toContain('influentialCitationCount');
expect(paper.columns).toContain('tldr');
});
it('marks every command as read access on the api.semanticscholar.org domain', () => {
for (const name of ['paper', 'citations', 'recommendations', 'search']) {
const cmd = getRegistry().get(`semanticscholar/${name}`);
expect(cmd, name).toBeDefined();
expect(cmd.access, name).toBe('read');
expect(cmd.domain, name).toBe('api.semanticscholar.org');
expect(cmd.browser, name).toBe(false);
}
});
});
describe('semanticscholar paper command', () => {
const command = getRegistry().get('semanticscholar/paper');
it('returns the paper row with citation graph and tldr', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
paperId: 'df2b0e26d0599ce3e70df8a9da02e51594e0e992',
title: 'BERT: Pre-training of Deep Bidirectional Transformers',
year: 2019,
authors: [{ name: 'Jacob Devlin' }, { name: 'Ming-Wei Chang' }],
citationCount: 116233,
influentialCitationCount: 22448,
referenceCount: 63,
tldr: { text: 'A new language representation model called BERT.' },
externalIds: { DOI: '10.18653/v1/N19-1423' },
url: 'https://www.semanticscholar.org/paper/df2b0e26d0599ce3e70df8a9da02e51594e0e992',
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ id: '10.18653/v1/N19-1423' })).resolves.toEqual([{
paperId: 'df2b0e26d0599ce3e70df8a9da02e51594e0e992',
doi: '10.18653/v1/N19-1423',
title: 'BERT: Pre-training of Deep Bidirectional Transformers',
year: 2019,
firstAuthor: 'Jacob Devlin',
citationCount: 116233,
influentialCitationCount: 22448,
referenceCount: 63,
tldr: 'A new language representation model called BERT.',
url: 'https://www.semanticscholar.org/paper/df2b0e26d0599ce3e70df8a9da02e51594e0e992',
}]);
// DOI normalization: bare 10.x DOIs get the DOI: prefix encoded into the path.
expect(fetchMock.mock.calls[0][0]).toContain('DOI%3A10.18653%2Fv1%2FN19-1423');
});
it('forwards SEMANTIC_SCHOLAR_API_KEY as the x-api-key header when set', async () => {
process.env.SEMANTIC_SCHOLAR_API_KEY = 'test-key';
const paperId = 'a'.repeat(40);
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ paperId, title: 'x' }));
vi.stubGlobal('fetch', fetchMock);
await command.func({ id: paperId });
expect(fetchMock.mock.calls[0][1].headers['x-api-key']).toBe('test-key');
});
it('rejects invalid identifiers before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ id: '' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ id: 'not a real reference' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('typed-fails malformed paper detail rows instead of treating parser drift as empty', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
await expect(command.func({ id: '10.0/missing' })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('typed-fails non-numeric metric fields instead of returning NaN', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
paperId: 'df2b0e26d0599ce3e70df8a9da02e51594e0e992',
title: 'Bad Metrics',
citationCount: 'many',
})));
await expect(command.func({ id: '10.0/bad-metrics' })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('semanticscholar citations command', () => {
const command = getRegistry().get('semanticscholar/citations');
it('unwraps citingPaper rows and offsets ranks correctly', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
data: [
{ citingPaper: { paperId: 'p1', title: 'Citing One', year: 2024, authors: [{ name: 'Alice' }], citationCount: 5, externalIds: { DOI: '10.1/p1' } } },
{ citingPaper: { paperId: 'p2', title: 'Citing Two', year: 2025, authors: [{ name: 'Bob' }], citationCount: 12, externalIds: {} } },
],
})));
await expect(command.func({ id: '10.18653/v1/N19-1423', limit: 2, offset: 10 })).resolves.toEqual([
{ rank: 11, paperId: 'p1', doi: '10.1/p1', title: 'Citing One', year: 2024, firstAuthor: 'Alice', citationCount: 5, url: 'https://www.semanticscholar.org/paper/p1' },
{ rank: 12, paperId: 'p2', doi: '', title: 'Citing Two', year: 2025, firstAuthor: 'Bob', citationCount: 12, url: 'https://www.semanticscholar.org/paper/p2' },
]);
});
it('rejects invalid arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ id: '', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ id: '10.0/x', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ id: '10.0/x', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps empty citation pages to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [] })));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails malformed payloads instead of returning empty rows', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ wrong: 'shape' })));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('typed-fails malformed citation rows without a citing paper identity', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
data: [{ citingPaper: { title: 'Missing identity' } }],
})));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('typed-fails citation entries missing citingPaper instead of emitting blank rows', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
data: [{ contexts: ['missing citing paper'] }],
})));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('semanticscholar recommendations command', () => {
const command = getRegistry().get('semanticscholar/recommendations');
it('returns recommendedPapers rows ranked from 1', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
recommendedPapers: [
{ paperId: 'r1', title: 'Related One', year: 2024, authors: [{ name: 'Carol' }], citationCount: 7, externalIds: { DOI: '10.1/r1' } },
],
})));
await expect(command.func({ id: '10.18653/v1/N19-1423', limit: 1 })).resolves.toEqual([
{ rank: 1, paperId: 'r1', doi: '10.1/r1', title: 'Related One', year: 2024, firstAuthor: 'Carol', citationCount: 7, url: 'https://www.semanticscholar.org/paper/r1' },
]);
});
it('maps empty recommendation responses to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ recommendedPapers: [] })));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails when the payload is missing recommendedPapers entirely', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ wrong: 'shape' })));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('typed-fails malformed recommendation rows without a stable paper identity', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
recommendedPapers: [{ title: 'Missing id' }],
})));
await expect(command.func({ id: '10.0/x', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('semanticscholar search command', () => {
const command = getRegistry().get('semanticscholar/search');
it('returns search rows that round-trip into semanticscholar paper', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
data: [{
paperId: 'sp1',
title: 'A Search Hit',
year: 2023,
authors: [{ name: 'Dana' }],
citationCount: 99,
externalIds: { DOI: '10.1/sp1' },
}],
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ query: 'transformers', limit: 1 })).resolves.toEqual([{
rank: 1,
paperId: 'sp1',
doi: '10.1/sp1',
title: 'A Search Hit',
year: 2023,
firstAuthor: 'Dana',
citationCount: 99,
url: 'https://www.semanticscholar.org/paper/sp1',
}]);
const url = new URL(fetchMock.mock.calls[0][0]);
expect(url.searchParams.get('query')).toBe('transformers');
});
it('rejects invalid arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ query: ' ', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ query: 'x', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ query: 'x', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('retries once on 429 when no API key is configured', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
.mockResolvedValueOnce(jsonResponse({ data: [{ paperId: 'after', title: 'Recovered', year: 2024, authors: [], citationCount: 0, externalIds: {} }] }));
vi.stubGlobal('fetch', fetchMock);
const rows = await command.func({ query: 'bursty', limit: 1 });
expect(rows[0].paperId).toBe('after');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('maps empty search results to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [] })));
await expect(command.func({ query: 'zz-no-hit', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('typed-fails malformed search rows without a stable paper identity', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
data: [{ title: 'Missing id' }],
})));
await expect(command.func({ query: 'broken row', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});