565 lines
20 KiB
TypeScript
565 lines
20 KiB
TypeScript
import { rm } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { MemoryStorage } from '@crawlee/memory-storage';
|
|
import type { DatasetClient, KeyValueStoreClient } from '@crawlee/types';
|
|
import { vi } from 'vitest';
|
|
|
|
import { createLazyIterablePromise } from '../src/utils';
|
|
|
|
describe('Async iteration support', () => {
|
|
const localDataDirectory = path.resolve(__dirname, './tmp/async-iteration');
|
|
const storage = new MemoryStorage({
|
|
localDataDirectory,
|
|
persistStorage: false,
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(localDataDirectory, { force: true, recursive: true });
|
|
});
|
|
|
|
describe('Dataset.listItems', () => {
|
|
const elements = Array.from({ length: 25 }, (_, i) => ({ index: i }));
|
|
let dataset: DatasetClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.datasets().getOrCreate('async-iteration-dataset');
|
|
dataset = storage.dataset(id);
|
|
await dataset.pushItems(elements);
|
|
});
|
|
|
|
test('can be awaited directly (backward compatibility)', async () => {
|
|
const result = await dataset.listItems({ limit: 10 });
|
|
|
|
expect(result.items).toHaveLength(10);
|
|
expect(result.total).toBe(25);
|
|
expect(result.offset).toBe(0);
|
|
expect(result.items).toStrictEqual(elements.slice(0, 10));
|
|
});
|
|
|
|
test('can be used with for await...of to iterate all items', async () => {
|
|
const items: { index: number }[] = [];
|
|
|
|
for await (const item of dataset.listItems()) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toHaveLength(25);
|
|
expect(items).toStrictEqual(elements);
|
|
});
|
|
|
|
test('respects limit option when iterating', async () => {
|
|
const items: { index: number }[] = [];
|
|
|
|
for await (const item of dataset.listItems({ limit: 10 })) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toHaveLength(10);
|
|
expect(items).toStrictEqual(elements.slice(0, 10));
|
|
});
|
|
|
|
test('respects offset option when iterating', async () => {
|
|
const items: { index: number }[] = [];
|
|
|
|
for await (const item of dataset.listItems({ offset: 5 })) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toHaveLength(20);
|
|
expect(items).toStrictEqual(elements.slice(5));
|
|
});
|
|
|
|
test('respects both offset and limit options when iterating', async () => {
|
|
const items: { index: number }[] = [];
|
|
|
|
for await (const item of dataset.listItems({ offset: 5, limit: 10 })) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toHaveLength(10);
|
|
expect(items).toStrictEqual(elements.slice(5, 15));
|
|
});
|
|
|
|
test('respects desc option when iterating', async () => {
|
|
const items: { index: number }[] = [];
|
|
|
|
for await (const item of dataset.listItems({ desc: true, limit: 5 })) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toHaveLength(5);
|
|
expect(items).toStrictEqual(elements.slice().reverse().slice(0, 5));
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.listKeys', () => {
|
|
const keys = Array.from({ length: 25 }, (_, i) => `key-${String(i).padStart(2, '0')}`);
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('async-iteration-kvs');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (const key of keys) {
|
|
await kvStore.setRecord({ key, value: { data: key } });
|
|
}
|
|
});
|
|
|
|
test('can be awaited directly (backward compatibility)', async () => {
|
|
const result = await kvStore.listKeys({ limit: 10 });
|
|
|
|
expect(result.items).toHaveLength(10);
|
|
expect(result.isTruncated).toBe(true);
|
|
expect(result.items.map((i) => i.key)).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('can be used with for await...of to iterate all keys', async () => {
|
|
const items: string[] = [];
|
|
|
|
for await (const item of kvStore.listKeys()) {
|
|
items.push(item.key);
|
|
}
|
|
|
|
expect(items).toHaveLength(25);
|
|
expect(items).toStrictEqual(keys);
|
|
});
|
|
|
|
test('respects limit option when iterating (10 items, limit 2)', async () => {
|
|
// Create a fresh store with exactly 10 items to match the reported bug scenario
|
|
const { id } = await storage.keyValueStores().getOrCreate('limit-test-kvs');
|
|
const testStore = storage.keyValueStore(id);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await testStore.setRecord({ key: `key-${i}`, value: `value-${i}` });
|
|
}
|
|
|
|
const items: string[] = [];
|
|
|
|
// This should only return 2 items, matching apify-client behavior
|
|
for await (const item of testStore.listKeys({ limit: 2 })) {
|
|
items.push(item.key);
|
|
}
|
|
|
|
// Should only get 2 items, not all 10
|
|
expect(items).toHaveLength(2);
|
|
});
|
|
|
|
test('respects exclusiveStartKey option when iterating', async () => {
|
|
const items: string[] = [];
|
|
|
|
// Start after key-04 (index 4), should get keys 5-24
|
|
for await (const item of kvStore.listKeys({ exclusiveStartKey: 'key-04' })) {
|
|
items.push(item.key);
|
|
}
|
|
|
|
expect(items).toHaveLength(20);
|
|
expect(items).toStrictEqual(keys.slice(5));
|
|
});
|
|
|
|
test('respects prefix option when iterating', async () => {
|
|
const items: string[] = [];
|
|
|
|
// Only keys starting with 'key-0' (key-00 to key-09)
|
|
for await (const item of kvStore.listKeys({ prefix: 'key-0' })) {
|
|
items.push(item.key);
|
|
}
|
|
|
|
expect(items).toHaveLength(10);
|
|
expect(items).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.keys', () => {
|
|
const keys = Array.from({ length: 25 }, (_, i) => `key-${String(i).padStart(2, '0')}`);
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('async-iteration-kvs-keys');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (const key of keys) {
|
|
await kvStore.setRecord({ key, value: { data: key } });
|
|
}
|
|
});
|
|
|
|
test('can be awaited directly (backward compatibility)', async () => {
|
|
const result = await kvStore.keys({ limit: 10 });
|
|
|
|
// When awaited, returns the same structure as listKeys
|
|
expect(result.items).toHaveLength(10);
|
|
expect(result.isTruncated).toBe(true);
|
|
expect(result.items.map((i) => i.key)).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('can be used with for await...of to iterate all keys as strings', async () => {
|
|
const items: string[] = [];
|
|
|
|
for await (const key of kvStore.keys()) {
|
|
items.push(key);
|
|
}
|
|
|
|
expect(items).toHaveLength(25);
|
|
expect(items).toStrictEqual(keys);
|
|
});
|
|
|
|
test('yields strings directly, not objects', async () => {
|
|
for await (const key of kvStore.keys()) {
|
|
expect(typeof key).toBe('string');
|
|
break; // Only need to check the first one
|
|
}
|
|
});
|
|
|
|
test('respects limit option when iterating', async () => {
|
|
const items: string[] = [];
|
|
|
|
for await (const key of kvStore.keys({ limit: 10 })) {
|
|
items.push(key);
|
|
}
|
|
|
|
expect(items).toHaveLength(10);
|
|
expect(items).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('respects exclusiveStartKey option when iterating', async () => {
|
|
const items: string[] = [];
|
|
|
|
// Start after key-04 (index 4), should get keys 5-24
|
|
for await (const key of kvStore.keys({ exclusiveStartKey: 'key-04' })) {
|
|
items.push(key);
|
|
}
|
|
|
|
expect(items).toHaveLength(20);
|
|
expect(items).toStrictEqual(keys.slice(5));
|
|
});
|
|
|
|
test('respects prefix option when iterating', async () => {
|
|
const items: string[] = [];
|
|
|
|
// Only keys starting with 'key-0' (key-00 to key-09)
|
|
for await (const key of kvStore.keys({ prefix: 'key-0' })) {
|
|
items.push(key);
|
|
}
|
|
|
|
expect(items).toHaveLength(10);
|
|
expect(items).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('respects both exclusiveStartKey and limit options', async () => {
|
|
const items: string[] = [];
|
|
|
|
for await (const key of kvStore.keys({ exclusiveStartKey: 'key-04', limit: 5 })) {
|
|
items.push(key);
|
|
}
|
|
|
|
expect(items).toHaveLength(5);
|
|
expect(items).toStrictEqual(keys.slice(5, 10));
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.values', () => {
|
|
const keys = Array.from({ length: 25 }, (_, i) => `key-${String(i).padStart(2, '0')}`);
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('async-iteration-kvs-values');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (const key of keys) {
|
|
await kvStore.setRecord({ key, value: { data: key } });
|
|
}
|
|
});
|
|
|
|
test('can be awaited directly (backward compatibility)', async () => {
|
|
const values = await kvStore.values({ limit: 10 });
|
|
|
|
expect(values).toHaveLength(10);
|
|
expect(Array.isArray(values)).toBe(true);
|
|
expect(values[0]).toStrictEqual({ data: 'key-00' });
|
|
});
|
|
|
|
test('can be used with for await...of to iterate all values', async () => {
|
|
const values: unknown[] = [];
|
|
|
|
for await (const value of kvStore.values()) {
|
|
values.push(value);
|
|
}
|
|
|
|
expect(values).toHaveLength(25);
|
|
expect(values.every((v) => v && typeof v === 'object')).toBe(true);
|
|
});
|
|
|
|
test('yields values directly, not KeyValueStoreRecord objects', async () => {
|
|
for await (const value of kvStore.values()) {
|
|
// Should be the actual value, not a record wrapper
|
|
expect(value).toStrictEqual({ data: 'key-00' });
|
|
expect(value).not.toHaveProperty('contentType');
|
|
break; // Only need to check the first one
|
|
}
|
|
});
|
|
|
|
test('respects limit option when iterating', async () => {
|
|
const values: unknown[] = [];
|
|
|
|
for await (const value of kvStore.values({ limit: 10 })) {
|
|
values.push(value);
|
|
}
|
|
|
|
expect(values).toHaveLength(10);
|
|
});
|
|
|
|
test('respects exclusiveStartKey option when iterating', async () => {
|
|
const values: unknown[] = [];
|
|
|
|
// Start after key-04 (index 4), should get keys 5-24
|
|
for await (const value of kvStore.values({ exclusiveStartKey: 'key-04' })) {
|
|
values.push(value);
|
|
}
|
|
|
|
expect(values).toHaveLength(20);
|
|
});
|
|
|
|
test('respects prefix option when iterating', async () => {
|
|
const values: unknown[] = [];
|
|
|
|
// Only keys starting with 'key-0' (key-00 to key-09)
|
|
for await (const value of kvStore.values({ prefix: 'key-0' })) {
|
|
values.push(value);
|
|
}
|
|
|
|
expect(values).toHaveLength(10);
|
|
});
|
|
|
|
test('fetches actual record values', async () => {
|
|
const values: unknown[] = [];
|
|
|
|
for await (const value of kvStore.values({ limit: 3 })) {
|
|
values.push(value);
|
|
}
|
|
|
|
expect(values[0]).toStrictEqual({ data: 'key-00' });
|
|
expect(values[1]).toStrictEqual({ data: 'key-01' });
|
|
expect(values[2]).toStrictEqual({ data: 'key-02' });
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.entries', () => {
|
|
const keys = Array.from({ length: 25 }, (_, i) => `key-${String(i).padStart(2, '0')}`);
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('async-iteration-kvs-entries');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (const key of keys) {
|
|
await kvStore.setRecord({ key, value: { data: key } });
|
|
}
|
|
});
|
|
|
|
test('can be awaited directly (backward compatibility)', async () => {
|
|
const entries = await kvStore.entries({ limit: 10 });
|
|
|
|
expect(entries).toHaveLength(10);
|
|
expect(Array.isArray(entries)).toBe(true);
|
|
// Each entry is a [key, value] tuple
|
|
expect(entries[0][0]).toBe('key-00');
|
|
expect(entries[0][1]).toStrictEqual({ data: 'key-00' });
|
|
});
|
|
|
|
test('can be used with for await...of to iterate all entries', async () => {
|
|
const entries: [string, unknown][] = [];
|
|
|
|
for await (const entry of kvStore.entries()) {
|
|
entries.push(entry);
|
|
}
|
|
|
|
expect(entries).toHaveLength(25);
|
|
expect(entries.map(([key]) => key)).toStrictEqual(keys);
|
|
});
|
|
|
|
test('yields [key, value] tuples', async () => {
|
|
for await (const [key, value] of kvStore.entries()) {
|
|
expect(typeof key).toBe('string');
|
|
expect(key).toBe('key-00');
|
|
expect(value).toStrictEqual({ data: 'key-00' });
|
|
// Value should not be a record wrapper
|
|
expect(value).not.toHaveProperty('contentType');
|
|
break; // Only need to check the first one
|
|
}
|
|
});
|
|
|
|
test('respects limit option when iterating', async () => {
|
|
const entries: [string, unknown][] = [];
|
|
|
|
for await (const entry of kvStore.entries({ limit: 10 })) {
|
|
entries.push(entry);
|
|
}
|
|
|
|
expect(entries).toHaveLength(10);
|
|
expect(entries.map(([key]) => key)).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('respects exclusiveStartKey option when iterating', async () => {
|
|
const entries: [string, unknown][] = [];
|
|
|
|
// Start after key-04 (index 4), should get keys 5-24
|
|
for await (const entry of kvStore.entries({ exclusiveStartKey: 'key-04' })) {
|
|
entries.push(entry);
|
|
}
|
|
|
|
expect(entries).toHaveLength(20);
|
|
expect(entries.map(([key]) => key)).toStrictEqual(keys.slice(5));
|
|
});
|
|
|
|
test('respects prefix option when iterating', async () => {
|
|
const entries: [string, unknown][] = [];
|
|
|
|
// Only keys starting with 'key-0' (key-00 to key-09)
|
|
for await (const entry of kvStore.entries({ prefix: 'key-0' })) {
|
|
entries.push(entry);
|
|
}
|
|
|
|
expect(entries).toHaveLength(10);
|
|
expect(entries.map(([key]) => key)).toStrictEqual(keys.slice(0, 10));
|
|
});
|
|
|
|
test('values in entries match expected data', async () => {
|
|
for await (const [key, value] of kvStore.entries({ limit: 5 })) {
|
|
expect(value).toStrictEqual({ data: key });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('createLazyIterablePromise', () => {
|
|
test('promise factory is not called until awaited', async () => {
|
|
const promiseFactory = vi.fn(() => Promise.resolve([1, 2, 3]));
|
|
async function* iteratorFactory() {
|
|
yield 1;
|
|
yield 2;
|
|
yield 3;
|
|
}
|
|
|
|
const result = createLazyIterablePromise<number[], number>(promiseFactory, iteratorFactory);
|
|
|
|
// Factory should not be called yet
|
|
expect(promiseFactory).not.toHaveBeenCalled();
|
|
|
|
// Now await it
|
|
const values = await result;
|
|
expect(promiseFactory).toHaveBeenCalledTimes(1);
|
|
expect(values).toStrictEqual([1, 2, 3]);
|
|
});
|
|
|
|
test('iterating does not trigger the promise factory', async () => {
|
|
const promiseFactory = vi.fn(() => Promise.resolve([1, 2, 3]));
|
|
async function* iteratorFactory() {
|
|
yield 10;
|
|
yield 20;
|
|
yield 30;
|
|
}
|
|
|
|
const result = createLazyIterablePromise<number[], number>(promiseFactory, iteratorFactory);
|
|
|
|
const items: number[] = [];
|
|
for await (const item of result) {
|
|
items.push(item);
|
|
}
|
|
|
|
expect(items).toStrictEqual([10, 20, 30]);
|
|
expect(promiseFactory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('promise factory result is cached across multiple awaits', async () => {
|
|
const promiseFactory = vi.fn(() => Promise.resolve([1, 2, 3]));
|
|
async function* iteratorFactory() {
|
|
yield 1;
|
|
}
|
|
|
|
const result = createLazyIterablePromise<number[], number>(promiseFactory, iteratorFactory);
|
|
|
|
await result;
|
|
await result;
|
|
await result;
|
|
|
|
expect(promiseFactory).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.values lazy promise behavior', () => {
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('lazy-test-kvs-values');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await kvStore.setRecord({ key: `key-${i}`, value: { data: i } });
|
|
}
|
|
});
|
|
|
|
test('calling values() does not immediately fetch records', async () => {
|
|
const getRecordSpy = vi.spyOn(kvStore, 'getRecord');
|
|
|
|
// Call values() but do not await or iterate
|
|
const result = kvStore.values();
|
|
|
|
// getRecord should not have been called yet (lazy)
|
|
// Note: keys may be fetched eagerly, but record values should not
|
|
// We need to wait a tick to ensure no async work triggered getRecord
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(getRecordSpy).not.toHaveBeenCalled();
|
|
|
|
// Clean up: consume the result to avoid dangling promises
|
|
await result;
|
|
getRecordSpy.mockRestore();
|
|
});
|
|
|
|
test('iterating and awaiting produce the same values', async () => {
|
|
const awaited = await kvStore.values();
|
|
|
|
const iterated: unknown[] = [];
|
|
for await (const value of kvStore.values()) {
|
|
iterated.push(value);
|
|
}
|
|
|
|
expect(awaited).toStrictEqual(iterated);
|
|
});
|
|
});
|
|
|
|
describe('KeyValueStore.entries lazy promise behavior', () => {
|
|
let kvStore: KeyValueStoreClient;
|
|
|
|
beforeAll(async () => {
|
|
const { id } = await storage.keyValueStores().getOrCreate('lazy-test-kvs-entries');
|
|
kvStore = storage.keyValueStore(id);
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await kvStore.setRecord({ key: `key-${i}`, value: { data: i } });
|
|
}
|
|
});
|
|
|
|
test('calling entries() does not immediately fetch records', async () => {
|
|
const getRecordSpy = vi.spyOn(kvStore, 'getRecord');
|
|
|
|
const result = kvStore.entries();
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(getRecordSpy).not.toHaveBeenCalled();
|
|
|
|
await result;
|
|
getRecordSpy.mockRestore();
|
|
});
|
|
|
|
test('iterating and awaiting produce the same entries', async () => {
|
|
const awaited = await kvStore.entries();
|
|
|
|
const iterated: [string, unknown][] = [];
|
|
for await (const entry of kvStore.entries()) {
|
|
iterated.push(entry);
|
|
}
|
|
|
|
expect(awaited).toStrictEqual(iterated);
|
|
});
|
|
});
|
|
});
|