import { execSync as execSyncOriginal } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { setTimeout } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { Actor, ApifyClient } from 'apify'; import fs from 'fs-extra'; import { got } from 'got'; import { URL_NO_COMMAS_REGEX } from '../../packages/utils/dist/index.mjs'; /** * @param {string} command * @param {import('node:child_process').ExecSyncOptions} options */ function execSync(command, options) { return execSyncOriginal(command, { ...options, encoding: 'utf-8' }); } /** * @param {string} name */ const isPrivateEntry = (name) => name === 'SDK_CRAWLER_STATISTICS_0' || name === 'SDK_SESSION_POOL_STATE'; export const SKIPPED_TEST_CLOSE_CODE = 404; /** @type {Record string>} */ export const colors = { red: (text) => `\x1B[31m${text}\x1B[39m`, green: (text) => `\x1B[32m${text}\x1B[39m`, grey: (text) => `\x1B[90m${text}\x1B[39m`, yellow: (text) => `\x1B[33m${text}\x1B[39m`, }; /** * @param {string} dirName */ export function getStorage(dirName) { let folderName; if (process.env.STORAGE_IMPLEMENTATION === 'LOCAL') folderName = 'apify_storage'; if (process.env.STORAGE_IMPLEMENTATION === 'MEMORY') folderName = 'storage'; return join(dirName, folderName); } /** * @param {string} dirName */ export async function getStats(dirName) { const dir = getStorage(dirName); const path = join(dir, `key_value_stores/default/SDK_CRAWLER_STATISTICS_0.json`); if (!existsSync(path)) { return false; } return fs.readJSON(path); } /** * @param {string | URL} url */ export function getActorTestDir(url) { const filename = fileURLToPath(url); const actorDirName = dirname(filename); return join(actorDirName, 'actor'); } export async function pushActor(client, dirName) { await copyPackages(dirName); try { // Capture all stdio (default would inherit stderr and flood the runner // with the platform's Docker/npm build output on every test). The // catch block below re-prints stdout/stderr if the push itself fails, // so failure diagnostics stay intact. maxBuffer bumped because the // build log can be tens of MB. execSync('apify push', { cwd: dirName, env: { ...process.env, GIT_CEILING_DIRECTORIES: dirname(dirName) }, stdio: 'pipe', maxBuffer: 50 * 1024 * 1024, }); } catch (err) { console.error(colors.red(`Failed to push actor to the Apify platform. (signal ${colors.yellow(err.signal)})`)); if (err.stdout) { console.log(colors.grey(` STDOUT: `), err.stdout); } if (err.stderr) { console.log(colors.red(` STDERR: `), err.stderr); } throw err; } const actorName = await getActorName(dirName); const { items: actors } = await client.actors().list(); const { id } = actors.find((actor) => actor.name === actorName); return id; } export async function startActorOnPlatform(client, id, input, inputContentType = 'application/json', memory = 4096) { const gotClient = got.extend({ retry: { limit: 2, statusCodes: [500, 502], }, headers: { 'user-agent': 'crawlee e2e tests (got)', }, timeout: { request: 10000, }, }); // Do NOT use Apify Client yet! // See https://github.com/apify/apify-client-js/issues/277 try { const { data: { id: foundRunId }, } = await gotClient(`https://api.apify.com/v2/acts/${id}/runs`, { method: 'POST', searchParams: { memory, }, headers: { 'content-type': inputContentType, authorization: `Bearer ${client.token}`, }, body: input, retry: { limit: 2, statusCodes: [500, 502], }, }).json(); return foundRunId; } catch (err) { console.error(colors.red(`Failed to start actor run on the Apify platform. (code ${colors.yellow(err.code)})`)); if (err.response) { console.log(colors.grey(` RESPONSE: `), err.response.body || err.response.rawBody?.toString('utf-8')); } throw err; } } /** * @param {string} dirName * @param {number} [memory=4096] */ export async function runActor(dirName, memory = 4096) { let stats; let datasetItems; let getKeyValueStoreItems; let defaultKeyValueStoreItems; const inputPath = join(dirName, '..', 'INPUT'); const input = fs.existsSync(inputPath) ? fs.readFileSync(inputPath) : undefined; const contentType = input ? 'application/json' : undefined; if (process.env.STORAGE_IMPLEMENTATION === 'PLATFORM') { // Plain client, not Actor.newClient(): the latter's charging-aware `.dataset()` // throws unless Actor.init() ran, which it never does in this harness process. const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const id = await pushActor(client, dirName); const runId = await startActorOnPlatform(client, id, input, contentType, memory); const { defaultKeyValueStoreId, defaultDatasetId, startedAt: runStartedAt, finishedAt: runFinishedAt, // id: runId, buildId, userId, } = await client.run(runId).waitForFinish(); getKeyValueStoreItems = async (name) => { const kvResult = await client.keyValueStore(name ? `${userId}/${name}` : defaultKeyValueStoreId).get(); if (kvResult) { const { items: keyValueItems } = await client.keyValueStore(kvResult.id).listKeys(); if (keyValueItems.length) { console.log(`[kv] View storage: https://console.apify.com/storage/key-value/${kvResult.id}`); } const entries = await Promise.all( keyValueItems.map(async ({ key }) => { const record = await client.keyValueStore(kvResult.id).getRecord(key, { buffer: true }); return { name: record.key, raw: record.value, }; }), ); return entries.filter(({ name }) => !isPrivateEntry(name)); } return undefined; }; const { startedAt: buildStartedAt, finishedAt: buildFinishedAt } = await client.build(buildId).get(); const buildTook = (buildFinishedAt.getTime() - buildStartedAt.getTime()) / 1000; console.log(`[build] View build log: https://api.apify.com/v2/logs/${buildId} [build took ${buildTook}s]`); const runTook = (runFinishedAt.getTime() - runStartedAt.getTime()) / 1000; console.log(`[run] View run: https://console.apify.com/view/runs/${runId} [run took ${runTook}s]`); const statsRecord = await client.keyValueStore(defaultKeyValueStoreId).getRecord('SDK_CRAWLER_STATISTICS_0'); stats = statsRecord?.value; const { items } = await client.dataset(defaultDatasetId).listItems(); datasetItems = items; defaultKeyValueStoreItems = await getKeyValueStoreItems(); } else { if (dirName.split('/').at(-2).endsWith('-ts')) { try { execSync('tsc', { cwd: dirName }); } catch (/** @type {any} */ e) { if ('stdout' in e) { console.error(e.stdout.toString()); } else { console.log(e); } // TODO rethrow or not? } } if (input) { await Actor.init({ // @ts-ignore installed only optionally run `run.mjs` script storage: process.env.STORAGE_IMPLEMENTATION === 'LOCAL' ? new (await import('@apify/storage-local')).ApifyStorageLocal() : undefined, }); await Actor.setValue('INPUT', input, { contentType }); } await import(join(dirName, 'main.js')); await setTimeout(50); stats = await getStats(dirName); datasetItems = await getDatasetItems(dirName); getKeyValueStoreItems = (name = 'default') => getLocalKeyValueStoreItems(dirName, name); defaultKeyValueStoreItems = await getKeyValueStoreItems(); } return { stats, datasetItems, defaultKeyValueStoreItems, getKeyValueStoreItems, }; } /** * @param {string} dirName */ async function getActorName(dirName) { const actorPackageFile = await fs.readJSON(join(dirName, 'package.json')); return actorPackageFile.name; } /** * In order to test the most recent 'Crawlee' changes we copy locally built packages, * push them to the platform together with actor code, * and install them there from the disk (not from NPM). * These changes are not merged to 'master' yet and thus not yet published to NPM. * @param {string} dirName * @internal */ async function copyPackages(dirName) { const srcPackagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'packages'); const destPackagesDir = join(dirName, 'packages'); await fs.remove(destPackagesDir); const { dependencies, overrides } = await fs.readJSON(join(dirName, 'package.json')); if (overrides?.apify) { Object.assign(dependencies, overrides.apify); } for (const dependency of Object.values(dependencies)) { if (!dependency.startsWith('file:')) { continue; } const packageDirName = dependency.split('/').pop(); const srcDir = join(srcPackagesDir, packageDirName, 'dist'); const destDir = join(destPackagesDir, packageDirName, 'dist'); await fs.copy(srcDir, destDir); const srcPackageFile = join(srcPackagesDir, packageDirName, 'package.json'); const destPackageFile = join(destPackagesDir, packageDirName, 'package.json'); await fs.copy(srcPackageFile, destPackageFile); } } /** * @param {string} dirName */ export async function clearPackages(dirName) { const destPackagesDir = join(dirName, 'actor', 'packages'); await fs.remove(destPackagesDir); } /** * @param {string} dirName */ export async function clearStorage(dirName) { let folderName; if (process.env.STORAGE_IMPLEMENTATION === 'LOCAL') folderName = 'apify_storage'; if (process.env.STORAGE_IMPLEMENTATION === 'MEMORY') folderName = 'storage'; const destPackagesDir = join(dirName, 'actor', folderName); await fs.remove(destPackagesDir); } export async function getApifyToken() { const authPath = join(homedir(), '.apify', 'auth.json'); if (!existsSync(authPath)) { throw new Error( 'You need to be logged in with your Apify account to run E2E tests. Call "apify login" to fix that.', ); } const { token } = await fs.readJSON(authPath); return token; } /** * @param {string} dirName */ export async function getDatasetItems(dirName) { const dir = getStorage(dirName); const datasetPath = join(dir, 'datasets/default/'); if (!existsSync(datasetPath)) { return []; } const dirents = await readdir(datasetPath, { withFileTypes: true }); const fileNames = dirents.filter((dirent) => dirent.isFile()); const datasetItems = []; for (const fileName of fileNames) { if (fileName.name.includes('__metadata__')) continue; const filePath = join(datasetPath, fileName.name); const datasetItem = await fs.readJSON(filePath); if (!isItemHidden(datasetItem)) { datasetItems.push(datasetItem); } } return datasetItems; } /** * Gets all items in the local key-value store * @param {string} dirName * @param {string} kvName */ export async function getLocalKeyValueStoreItems(dirName, kvName) { const dir = getStorage(dirName); const storePath = join(dir, 'key_value_stores', kvName); if (!existsSync(storePath)) { return undefined; } const dirents = await readdir(storePath, { withFileTypes: true }); const fileNames = dirents.filter((dirent) => dirent.isFile()); const keyValueStoreRecords = []; for (const fileName of fileNames) { if (fileName.name.includes('__metadata__')) continue; const filePath = join(storePath, fileName.name); const buffer = await readFile(filePath); const name = fileName.name.split('.').slice(0, -1).join('.'); if (isPrivateEntry(name)) { continue; } keyValueStoreRecords.push({ name, raw: buffer }); } return keyValueStoreRecords; } /** * @param {string} dirName */ export async function initialize(dirName) { process.env.STORAGE_IMPLEMENTATION ??= 'MEMORY'; if (process.env.STORAGE_IMPLEMENTATION !== 'PLATFORM') { process.env.APIFY_LOCAL_STORAGE_DIR = getStorage(dirName); process.env.CRAWLEE_STORAGE_DIR = getStorage(dirName); process.env.APIFY_HEADLESS = '1'; // run browser in headless mode (default on platform) process.env.APIFY_TOKEN ??= await getApifyToken(); process.env.APIFY_CONTAINER_URL ??= 'http://127.0.0.1'; process.env.APIFY_CONTAINER_PORT ??= '8000'; } console.log('[init] Storage directory:', process.env.APIFY_LOCAL_STORAGE_DIR || 'n/a (running on the platform)'); } /** * @param {boolean} bool * @param {string} message */ export async function expect(bool, message) { if (bool) { console.log(`[assertion] passed: ${message}`); await setTimeout(10); } else { console.log(`[assertion] failed: ${message}`); await setTimeout(10); process.exit(1); } } /** * @param {string} reason */ export async function skipTest(reason) { console.error(`[test skipped] ${reason}`); process.exit(SKIPPED_TEST_CLOSE_CODE); } /** * @param {Record} item * @param {string} propName * @returns {boolean} */ function checkDatasetItem(item, propName) { if (!item.hasOwnProperty(propName)) { return false; } switch (propName) { case 'url': return item.url.match(URL_NO_COMMAS_REGEX); case 'modifiedDate': return !Number.isNaN(Date.parse(item.modifiedDate)); case 'runCount': return Number.isInteger(item.runCount); default: return ['string', 'number', 'boolean'].includes(typeof item[propName]); } } /** * @param {any[]} items * @param {string[]} schema */ export function validateDataset(items, schema = []) { for (const item of items) { for (const propName of schema) { if (!checkDatasetItem(item, propName)) { return false; } } } return true; } /** * @param {Record} item */ function isItemHidden(item) { for (const key of Object.keys(item)) { if (!key.startsWith('#')) { return false; } } return true; } /** * @param {any} obj the object to search * @param {string} keyName the key to search for * @returns {boolean} */ export function hasNestedKey(obj, keyName) { if (typeof obj !== 'object' || obj === null) { return false; } for (const key of Object.keys(obj)) { if (key === keyName) { return true; } if (typeof obj[key] === 'object' && obj[key] !== null && hasNestedKey(obj[key], keyName)) { return true; } } return false; }