Files
2026-07-13 13:23:39 +08:00

850 lines
34 KiB
TypeScript

import {
Configuration,
deserializeArray,
EventType,
KeyValueStore,
ProxyConfiguration,
Request,
RequestList,
} from '@crawlee/core';
import type { gotScraping } from '@crawlee/utils';
import { sleep } from '@crawlee/utils';
import { beforeAll, type MockedFunction } from 'vitest';
import log from '@apify/log';
import { MemoryStorageEmulator } from '../shared/MemoryStorageEmulator';
/**
* Stand-in for underscore.js shuffle (weird, but how else?)
*/
function shuffle(array: unknown[]): unknown[] {
const out = [...array];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
vitest.mock('@crawlee/utils/src/internals/gotScraping', async () => {
return {
gotScraping: vitest.fn(),
};
});
let gotScrapingSpy: MockedFunction<typeof gotScraping>;
beforeAll(async () => {
// @ts-ignore for some reason, this fails when the project is not built :/
const { gotScraping } = await import('@crawlee/utils');
gotScrapingSpy = vitest.mocked(gotScraping);
});
describe('RequestList', () => {
let ll: number;
const emulator = new MemoryStorageEmulator();
const events = Configuration.getEventManager();
beforeAll(() => {
ll = log.getLevel();
log.setLevel(log.LEVELS.ERROR);
});
beforeEach(async () => {
await emulator.init();
vitest.restoreAllMocks();
});
afterAll(async () => {
log.setLevel(ll);
await emulator.destroy();
});
test('should not accept to pages with same uniqueKey', async () => {
const requestList = await RequestList.open(null, [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/1#same' },
]);
expect(await requestList.isEmpty()).toBe(false);
const req = await requestList.fetchNextRequest();
expect(req!.url).toBe('https://example.com/1');
expect(await requestList.isEmpty()).toBe(true);
expect(await requestList.isFinished()).toBe(false);
expect(await requestList.fetchNextRequest()).toBe(null);
await requestList.markRequestHandled(req!);
expect(await requestList.isEmpty()).toBe(true);
expect(await requestList.isFinished()).toBe(true);
});
test('must be initialized before using any of the methods', async () => {
// @ts-expect-error private constructor
const requestList = new RequestList({ sources: [{ url: 'https://example.com' }] });
const requestObj = new Request({ url: 'https://example.com' });
await expect(requestList.isEmpty()).rejects.toThrow();
await expect(requestList.isFinished()).rejects.toThrow();
expect(() => requestList.getState()).toThrowError();
await expect(requestList.markRequestHandled(requestObj)).rejects.toThrow();
await expect(requestList.reclaimRequest(requestObj)).rejects.toThrow();
await expect(requestList.fetchNextRequest()).rejects.toThrow();
await requestList.initialize();
await expect(requestList.isEmpty()).resolves.not.toThrow();
await expect(requestList.isFinished()).resolves.not.toThrow();
expect(() => requestList.getState()).not.toThrowError();
await expect(requestList.fetchNextRequest()).resolves.not.toThrow();
await expect(requestList.reclaimRequest(requestObj)).resolves.not.toThrow();
await expect(requestList.fetchNextRequest()).resolves.not.toThrow();
await expect(requestList.markRequestHandled(requestObj)).resolves.not.toThrow();
});
test('should correctly initialize itself', async () => {
const sources = [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/2' },
{ url: 'https://example.com/3' },
{ url: 'https://example.com/4' },
{ url: 'https://example.com/5' },
{ url: 'https://example.com/6' },
{ url: 'https://example.com/7' },
{ url: 'https://example.com/8' },
];
const sourcesCopy = JSON.parse(JSON.stringify(sources));
const originalList = await RequestList.open(null, sources);
const r1 = await originalList.fetchNextRequest(); // 1
const r2 = await originalList.fetchNextRequest(); // 2
await originalList.fetchNextRequest(); // 3
const r4 = await originalList.fetchNextRequest(); // 4
const r5 = await originalList.fetchNextRequest(); // 5
await originalList.fetchNextRequest(); // 6
await originalList.markRequestHandled(r1!);
await originalList.markRequestHandled(r2!);
await originalList.markRequestHandled(r4!);
await originalList.reclaimRequest(r5!);
const newList = await RequestList.open({
sources: sourcesCopy,
state: originalList.getState(),
});
expect(await newList.isEmpty()).toBe(false);
expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/3');
expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/5');
expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/6');
expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/7');
expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/8');
expect(await newList.isEmpty()).toBe(true);
});
test('`RequestList` is `for .. await` iterable', async () => {
const sources = [
'https://example.com/1',
'https://example.com/2',
'https://example.com/3',
'https://example.com/4',
'https://example.com/5',
'https://example.com/6',
'https://example.com/7',
'https://example.com/8',
];
const requestList = await RequestList.open(null, sources);
for await (const request of requestList) {
expect(request?.url).toBe(sources.shift());
}
});
test('should correctly load list from hosted files in correct order', async () => {
const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
const list1 = ['https://example.com', 'https://google.com', 'https://wired.com'];
const list2 = ['https://another.com', 'https://page.com'];
spy.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(list1) as any, 100)) as any);
spy.mockResolvedValueOnce(list2);
const requestList = await RequestList.open({
sources: [
{ method: 'GET', requestsFromUrl: 'http://example.com/list-1' },
{ method: 'POST', requestsFromUrl: 'http://example.com/list-2' },
],
});
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[0] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[1] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[2] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'POST', url: list2[0] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'POST', url: list2[1] });
expect(spy).toBeCalledTimes(2);
expect(spy).toBeCalledWith({ url: 'http://example.com/list-1', urlRegExp: undefined });
expect(spy).toBeCalledWith({ url: 'http://example.com/list-2', urlRegExp: undefined });
});
test('should use regex parameter to parse urls', async () => {
const listStr = 'kjnjkn"https://example.com/a/b/c?q=1#abc";,"HTTP://google.com/a/b/c";dgg:dd';
const listArr = ['https://example.com', 'HTTP://google.com'];
gotScrapingSpy.mockResolvedValue({ body: listStr } as any);
const regex = /(https:\/\/example.com|HTTP:\/\/google.com)/g;
const requestList = await RequestList.open({
sources: [
{
method: 'GET',
requestsFromUrl: 'http://example.com/list-1',
regex,
},
],
});
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: listArr[0] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: listArr[1] });
expect(gotScrapingSpy).toBeCalledWith({ url: 'http://example.com/list-1', encoding: 'utf8' });
});
test('should fix gdoc sharing url in `requestsFromUrl` automatically (GH issue #639)', async () => {
const list = ['https://example.com', 'https://google.com', 'https://wired.com'];
const wrongUrls = [
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU',
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/',
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/edit?usp=sharing',
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/123123132',
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/?q=blablabla',
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/edit#gid=0',
];
const correctUrl =
'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/gviz/tq?tqx=out:csv';
gotScrapingSpy.mockResolvedValue({ body: JSON.stringify(list) } as any);
const requestList = await RequestList.open({
sources: wrongUrls.map((requestsFromUrl) => ({ requestsFromUrl })),
});
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[0] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[1] });
expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[2] });
expect(gotScrapingSpy).toBeCalledWith({ url: correctUrl, encoding: 'utf8' });
});
test('should handle requestsFromUrl with no URLs', async () => {
const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
spy.mockResolvedValueOnce([]);
const requestList = await RequestList.open({
sources: [
{
method: 'GET',
requestsFromUrl: 'http://example.com/list-1',
},
],
});
expect(await requestList.fetchNextRequest()).toBe(null);
expect(spy).toBeCalledTimes(1);
expect(spy).toBeCalledWith({ url: 'http://example.com/list-1', urlRegExp: undefined });
});
test('should use the defined proxy server when using `requestsFromUrl`', async () => {
const proxyUrls = ['http://proxyurl.usedforthe.download', 'http://another.proxy.url'];
const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
spy.mockResolvedValue([]);
const proxyConfiguration = new ProxyConfiguration({
proxyUrls,
});
const requestList = await RequestList.open({
sources: [
{ requestsFromUrl: 'http://example.com/list-1' },
{ requestsFromUrl: 'http://example.com/list-2' },
{ requestsFromUrl: 'http://example.com/list-3' },
],
proxyConfiguration,
});
expect(spy).not.toBeCalledWith(expect.not.objectContaining({ proxyUrl: expect.any(String) }));
});
test('should correctly handle reclaimed pages', async () => {
const requestList = await RequestList.open({
sources: [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/2' },
{ url: 'https://example.com/3' },
{ url: 'https://example.com/4' },
{ url: 'https://example.com/5' },
{ url: 'https://example.com/6' },
],
});
//
// Fetch first 5 urls
//
const request1 = await requestList.fetchNextRequest();
const request2 = await requestList.fetchNextRequest();
const request3 = await requestList.fetchNextRequest();
const request4 = await requestList.fetchNextRequest();
const request5 = await requestList.fetchNextRequest();
expect(request1!.url).toBe('https://example.com/1');
expect(request2!.url).toBe('https://example.com/2');
expect(request3!.url).toBe('https://example.com/3');
expect(request4!.url).toBe('https://example.com/4');
expect(request5!.url).toBe('https://example.com/5');
expect(requestList.getState()).toEqual({
inProgress: [
'https://example.com/1',
'https://example.com/2',
'https://example.com/3',
'https://example.com/4',
'https://example.com/5',
],
nextIndex: 5,
nextUniqueKey: 'https://example.com/6',
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress.size).toBe(5);
expect(requestList.reclaimed.size).toBe(0);
//
// Mark 1st, 2nd handled
// Reclaim 3rd 4th
//
await requestList.markRequestHandled(request1!);
await requestList.markRequestHandled(request2!);
await requestList.reclaimRequest(request3!);
await requestList.reclaimRequest(request4!);
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/3', 'https://example.com/4', 'https://example.com/5'],
nextIndex: 5,
nextUniqueKey: 'https://example.com/6',
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Mark 5th handled
//
await requestList.markRequestHandled(request5!);
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/3', 'https://example.com/4'],
nextIndex: 5,
nextUniqueKey: 'https://example.com/6',
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Fetch 3rd and 4th
// Mark 4th handled
//
const reclaimed3 = await requestList.fetchNextRequest();
expect(reclaimed3!.url).toBe('https://example.com/3');
const reclaimed4 = await requestList.fetchNextRequest();
expect(reclaimed4!.url).toBe('https://example.com/4');
await requestList.markRequestHandled(request4!);
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/3'],
nextIndex: 5,
nextUniqueKey: 'https://example.com/6',
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Mark 3rd handled
//
await requestList.markRequestHandled(request3!);
expect(requestList.getState()).toEqual({
inProgress: [],
nextIndex: 5,
nextUniqueKey: 'https://example.com/6',
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Fetch 6th
//
const request6 = await requestList.fetchNextRequest();
expect(request6!.url).toBe('https://example.com/6');
expect(await requestList.fetchNextRequest()).toBe(null);
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/6'],
nextIndex: 6,
nextUniqueKey: null,
});
expect(await requestList.isEmpty()).toBe(true);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Reclaim 6th
//
await requestList.reclaimRequest(request6!);
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/6'],
nextIndex: 6,
nextUniqueKey: null,
});
expect(await requestList.isEmpty()).toBe(false);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Fetch 6th
//
const reclaimed6 = await requestList.fetchNextRequest();
expect(reclaimed6!.url).toBe('https://example.com/6');
expect(requestList.getState()).toEqual({
inProgress: ['https://example.com/6'],
nextIndex: 6,
nextUniqueKey: null,
});
expect(await requestList.isEmpty()).toBe(true);
expect(await requestList.isFinished()).toBe(false);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
//
// Mark 6th handled
//
await requestList.markRequestHandled(reclaimed6!);
expect(requestList.getState()).toEqual({
inProgress: [],
nextIndex: 6,
nextUniqueKey: null,
});
expect(await requestList.isEmpty()).toBe(true);
expect(await requestList.isFinished()).toBe(true);
expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
});
test('should correctly persist its state when persistStateKey is set', async () => {
const PERSIST_STATE_KEY = 'some-key';
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
getValueSpy.mockResolvedValueOnce(null);
const opts = {
sources: [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/2' },
{ url: 'https://example.com/3' },
],
persistStateKey: PERSIST_STATE_KEY,
};
const optsCopy = JSON.parse(JSON.stringify(opts));
const requestList = await RequestList.open(opts);
expect(requestList.isStatePersisted).toBe(true);
// Fetch one request and check that state is not persisted.
const request1 = await requestList.fetchNextRequest();
expect(requestList.isStatePersisted).toBe(false);
// Persist state.
setValueSpy.mockResolvedValueOnce();
events.emit(EventType.PERSIST_STATE);
await sleep(20);
expect(requestList.isStatePersisted).toBe(true);
// Do some other changes and persist it again.
const request2 = await requestList.fetchNextRequest();
expect(requestList.isStatePersisted).toBe(false);
await requestList.markRequestHandled(request2!);
expect(requestList.isStatePersisted).toBe(false);
setValueSpy.mockResolvedValueOnce();
events.emit(EventType.PERSIST_STATE);
await sleep(20);
expect(requestList.isStatePersisted).toBe(true);
// Reclaim event doesn't change the state.
await requestList.reclaimRequest(request1!);
expect(requestList.isStatePersisted).toBe(true);
// Now initiate new request list from saved state and check that it's same as state
// of original request list.
getValueSpy.mockResolvedValueOnce(requestList.getState());
const requestList2 = await RequestList.open(optsCopy);
expect(requestList2.getState()).toEqual(requestList.getState());
});
test('should correctly persist its sources when persistRequestsKey is set', async () => {
const PERSIST_REQUESTS_KEY = 'some-key';
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
let persistedRequests;
const opts = {
sources: [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/2' },
{ url: 'https://example.com/3' },
],
persistRequestsKey: PERSIST_REQUESTS_KEY,
};
// Expect an attempt to load sources.
getValueSpy.mockResolvedValueOnce(null);
// Expect persist sources.
setValueSpy.mockImplementationOnce(async (_key, value) => {
persistedRequests = value;
});
const requestList = await RequestList.open(opts);
expect(requestList.areRequestsPersisted).toBe(true);
const opts2 = {
sources: [{ url: 'https://test.com/1' }, { url: 'https://test.com/2' }, { url: 'https://test.com/3' }],
persistRequestsKey: PERSIST_REQUESTS_KEY,
};
getValueSpy.mockResolvedValueOnce(persistedRequests);
const requestList2 = await RequestList.open(opts2);
expect(requestList2.areRequestsPersisted).toBe(true);
expect(requestList2.requests).toEqual(requestList.requests);
});
test('should correctly persist sources from requestsFromUrl if persistRequestsKey is set', async () => {
const PERSIST_REQUESTS_KEY = 'some-key';
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
let persistedRequests: any;
const opts = {
sources: [
{ url: 'https://example.com/1' },
{ url: 'https://example.com/2' },
{ requestsFromUrl: 'http://example.com/list-urls.txt', userData: { isFromUrl: true } },
{ url: 'https://example.com/5' },
],
persistRequestsKey: PERSIST_REQUESTS_KEY,
};
const urlsFromTxt = ['http://example.com/3', 'http://example.com/4'];
spy.mockResolvedValueOnce(urlsFromTxt);
getValueSpy.mockResolvedValueOnce(null);
setValueSpy.mockImplementationOnce(async (_key, value) => {
persistedRequests = value;
});
const requestList = await RequestList.open(opts);
expect(requestList.areRequestsPersisted).toBe(true);
const requests = await deserializeArray(persistedRequests);
expect(requestList.requests).toHaveLength(5);
expect(requests).toEqual(requestList.requests);
expect(spy).toBeCalledTimes(1);
expect(spy).toBeCalledWith({ url: 'http://example.com/list-urls.txt', urlRegExp: undefined });
});
test('handles correctly inconsistent inProgress fields in state', async () => {
// NOTE: This is a test for the deleteFromInProgress hotfix - see RequestList.initialize()
const sources = [
{ url: 'https://www.ams360.com' },
{ url: 'https://www.anybus.com' },
{ url: 'https://www.anychart.com' },
{ url: 'https://www.example.com' },
];
const state = {
nextIndex: 2,
nextUniqueKey: 'https://www.anychart.com',
inProgress: ['https://www.ams360.com', 'https://www.anybus.com', 'https://www.anychart.com'],
};
const requestList = await RequestList.open({
sources,
state,
});
// Get requests from list
let reqs: Request[] = [];
for (let i = 0; i < 5; i++) {
const request = await requestList.fetchNextRequest();
if (!request) break;
reqs.push(request);
}
reqs = shuffle(reqs) as typeof reqs;
for (let i = 0; i < reqs.length; i++) {
await requestList.reclaimRequest(reqs[i]);
}
});
test('it gets correct length()', async () => {
const sources = [
{ url: 'https://www.example.com' },
{ url: 'https://www.ams360.com' },
{ url: 'https://www.anybus.com' },
{ url: 'https://www.anychart.com' },
{ url: 'https://www.example.com' },
];
const requestList = await RequestList.open({
sources,
});
expect(requestList.length()).toBe(4);
});
test('it gets correct handledCount()', async () => {
const sources = [
{ url: 'https://www.example.com' },
{ url: 'https://www.ams360.com' },
{ url: 'https://www.anybus.com' },
{ url: 'https://www.anychart.com' },
{ url: 'https://www.example.com' },
];
const requestList = await RequestList.open({
sources,
});
const req1 = await requestList.fetchNextRequest();
const req2 = await requestList.fetchNextRequest();
const req3 = await requestList.fetchNextRequest();
expect(requestList.handledCount()).toBe(0);
await requestList.markRequestHandled(req2!);
expect(requestList.handledCount()).toBe(1);
await requestList.markRequestHandled(req3!);
expect(requestList.handledCount()).toBe(2);
await requestList.reclaimRequest(req1!);
expect(requestList.handledCount()).toBe(2);
});
test('should correctly keep duplicate URLs while keepDuplicateUrls is set', async () => {
const sources = [
{ url: 'https://www.example.com' },
{ url: 'https://www.example.com' },
{ url: 'https://www.example.com' },
{ url: 'https://www.ex2mple.com' },
];
const sourcesCopy = JSON.parse(JSON.stringify(sources));
let requestList = await RequestList.open({
sources,
keepDuplicateUrls: true,
});
expect(requestList.length()).toBe(4);
log.setLevel(log.LEVELS.INFO);
const warnSpy = vitest.spyOn(console, 'warn').mockImplementation(() => {});
requestList = await RequestList.open({
sources: sourcesCopy.concat([
{ url: 'https://www.example.com', uniqueKey: '123' },
{ url: 'https://www.example.com', uniqueKey: '123' },
{ url: 'https://www.example.com', uniqueKey: '456' },
{ url: 'https://www.ex2mple.com', uniqueKey: '456' },
]),
keepDuplicateUrls: true,
});
expect(requestList.length()).toBe(6);
expect(warnSpy).toBeCalled();
expect(warnSpy.mock.calls[0][0]).toMatch(`Check your sources' unique keys.`);
log.setLevel(log.LEVELS.ERROR);
});
describe('Apify.RequestList.open()', () => {
test('should work', async () => {
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
const name = 'xxx';
const SDK_KEY = `SDK_${name}`;
const sources = [{ url: 'https://example.com' }];
const rl = await RequestList.open(name, sources);
expect(rl).toBeInstanceOf(RequestList);
// @ts-expect-error accessing private var
expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
// @ts-expect-error accessing private var
expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
// @ts-expect-error accessing private var
expect(rl.sources).toEqual([]);
// @ts-expect-error accessing private var
expect(rl.isInitialized).toBe(true);
expect(getValueSpy).toBeCalledTimes(2);
expect(setValueSpy).toBeCalledTimes(1);
});
test('should work with string sources', async () => {
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
const name = 'xxx';
const SDK_KEY = `SDK_${name}`;
const sources = ['https://example.com'];
const requests = sources.map((url) => ({ url, uniqueKey: url }));
const rl = await RequestList.open(name, sources);
expect(rl).toBeInstanceOf(RequestList);
// @ts-expect-error accessing private var
expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
// @ts-expect-error accessing private var
expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
expect(rl.requests).toEqual(requests);
// @ts-expect-error accessing private var
expect(rl.isInitialized).toBe(true);
expect(getValueSpy).toBeCalledTimes(2);
expect(setValueSpy).toBeCalledTimes(1);
});
test('should correctly pass options', async () => {
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
const name = 'xxx';
const SDK_KEY = `SDK_${name}`;
let counter = 0;
const sources = [{ url: 'https://example.com' }];
const requests = sources.map(({ url }) => ({ url, uniqueKey: `${url}-${counter++}` }));
const options = {
keepDuplicateUrls: true,
persistStateKey: 'yyy',
};
const rl = await RequestList.open(name, sources, options);
expect(rl).toBeInstanceOf(RequestList);
// @ts-expect-error accessing private var
expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
// @ts-expect-error accessing private var
expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
expect(rl.requests).toEqual(requests);
// @ts-expect-error accessing private var
expect(rl.isInitialized).toBe(true);
// @ts-expect-error accessing private var
expect(rl.keepDuplicateUrls).toBe(true);
expect(getValueSpy).toBeCalledTimes(2);
expect(setValueSpy).toBeCalledTimes(1);
});
test('should work with null name', async () => {
const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
const name: string | null = null;
const sources = [{ url: 'https://example.com' }];
const requests = sources.map(({ url }) => ({ url, uniqueKey: url }));
const rl = await RequestList.open(name, sources);
expect(rl).toBeInstanceOf(RequestList);
// @ts-expect-error accessing private var
expect(rl.persistStateKey == null).toBe(true);
// @ts-expect-error accessing private var
expect(rl.persistRequestsKey == null).toBe(true);
expect(rl.requests).toEqual(requests);
// @ts-expect-error accessing private var
expect(rl.isInitialized).toBe(true);
expect(getValueSpy).not.toBeCalled();
expect(setValueSpy).not.toBeCalled();
});
test('should throw on invalid parameters', async () => {
const args = [[], ['x', {}], ['x', 6, {}], ['x', [], []]] as const;
for (const arg of args) {
try {
// @ts-ignore
await RequestList.open(...arg);
throw new Error('wrong error');
} catch (err) {
const e = err as Error;
expect(e.message).not.toBe('wrong error');
if (e.message.match('argument to be of type `string`')) {
expect(e.message).toMatch('received type `undefined`');
} else if (e.message.match('argument to be of type `array`')) {
const isMatched =
e.message.match('received type `Object`') ||
e.message.match('received type `number`') ||
e.message.match('received type `undefined`');
expect(isMatched).toBeTruthy();
} else if (e.message.match('argument to be of type `null`')) {
expect(e.message).toMatch('received type `undefined`');
}
}
}
});
});
// This test is here to run locally. It would take too long
// when running a test suite and in CI with large source arrays
// and would be flaky with small source arrays, so manual inspection
// looks like the best idea, since multiple runs with various values
// need to be tested and compared (read: I'm too lazy to automate this)
// test('memory consumption does not spike', async () => {
// function getMemoryInMbytes() {
// const memory = process.memoryUsage();
// return (memory.heapUsed + memory.external) / 1024 / 1024;
// }
// const sources = [];
// for (let i = 0; i < 1e6; i++) {
// sources.push({ url: `https://example.com?page=${i}` });
// }
// const startingMemory = getMemoryInMbytes();
// console.log(startingMemory, 'MB');
//
// process.env.APIFY_LOCAL_STORAGE_DIR = 'tmp';
// const rl = await RequestList.open({ sources, persistRequestsKey: null });
// const instanceMemory = getMemoryInMbytes();
// console.log(instanceMemory, 'MB');
//
// const initMemory = getMemoryInMbytes();
// console.log(initMemory, 'MB');
// });
});