diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 23307d8a2..02e8d5889 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -9,6 +9,7 @@ import { fetchManifest } from '@apify/actor-templates'; import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; import { createHmacSignature } from '@apify/utilities'; +import { checkActorPaths, formatActorPathProblems } from '../../lib/actor-paths.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -273,8 +274,7 @@ export class ActorsPushCommand extends ApifyCommand { return; } - const apifyClient = await getLoggedClientOrThrow(); - + // Everything up to the path checks is local, so a broken Actor fails the same way with or without a login. const actorConfigResult = await useActorConfig({ cwd }); if (actorConfigResult.isErr()) { @@ -299,6 +299,25 @@ export class ActorsPushCommand extends ApifyCommand { return; } + // The platform resolves these paths after the upload; a wrong one builds the wrong image or fails the build. + const pathProblems = checkActorPaths({ + actorDir: cwd, + uploadRoot: cwd, + uploadedFiles: filePathsToPush, + config: actorConfig, + dockerContextRoot: dockerContext.kind === 'within' ? dockerContext.contextRoot : undefined, + }); + // A readme or changelog the platform cannot find only falls back to its default, so push goes on. + const pathWarnings = formatActorPathProblems(pathProblems, 'warning'); + if (pathWarnings) warning({ message: pathWarnings }); + const pathErrors = formatActorPathProblems(pathProblems, 'error'); + if (pathErrors) { + error({ message: pathErrors }); + process.exitCode = CommandExitCodes.InvalidActorJson; + return; + } + + const apifyClient = await getLoggedClientOrThrow(); const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; diff --git a/src/lib/actor-paths.ts b/src/lib/actor-paths.ts new file mode 100644 index 000000000..721a405ae --- /dev/null +++ b/src/lib/actor-paths.ts @@ -0,0 +1,289 @@ +import { readdirSync, realpathSync, type Stats, statSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'; + +import { ACTOR_SPECIFICATION_FOLDER } from './consts.js'; +import { isOutside, toPosix } from './docker-context.js'; + +/** + * An `error` would fail the build or build the wrong thing, so push stops. A `warning` is a path the platform cannot + * find but recovers from with its default lookup, so the build succeeds and push continues. + */ +export type ActorPathSeverity = 'error' | 'warning'; + +export interface ActorPathProblem { + field: string; + /** Empty when the field is not set and the problem lies with what the platform uses by default. */ + value: string; + resolvedPath: string; + reason: string; + severity: ActorPathSeverity; +} + +export interface ActorPathsOptions { + actorDir: string; + /** What push sends: the Actor folder, or the source root of a monorepo Actor. */ + uploadRoot: string; + /** Relative to `uploadRoot`, as the file collectors return them. */ + uploadedFiles: string[]; + config: Record; + /** + * When given, `dockerfile` must lie inside it. The worker runs BuildKit with `--local dockerfile=` + * and `filename=`, so a Dockerfile outside the context cannot be read by the + * build. The same holds for a context within the Actor folder, which today's push already sends. Without + * `dockerfile`, the Dockerfile the platform would find by default is held to the same rules. Pass it as + * `resolveDockerContext` returns it: the path as written, from the real Actor folder. + */ + dockerContextRoot?: string; +} + +/** The platform finds these by a directory listing, so their names match case-insensitively. */ +const LISTED_FILE_FIELDS = ['dockerfile', 'readme', 'changelog']; +/** The worker copies a set alias over its base key before resolving, so the alias wins and the base key is ignored. */ +const SCHEMA_FIELDS: { field: string; alias?: string }[] = [ + { field: 'input', alias: 'inputSchema' }, + { field: 'output', alias: 'outputSchema' }, + { field: 'webServerSchema' }, +]; +const STORAGE_SCHEMA_FIELDS = ['keyValueStore', 'dataset']; +/** + * When the worker cannot find the file these name, it logs a warning and falls back to its default lookup + * (`.actor/ACTOR.md`, `.actor/README.md`, then `README.md` or `README` in the Actor folder; likewise for the + * changelog), so the build succeeds. A missing `dockerfile` falls back too, but to a Dockerfile the user did not choose. + */ +const FALLBACK_FIELDS = new Set(['readme', 'changelog']); +/** + * Without `dockerfile`, the worker takes the first of these that exists, relative to `.actor/`; with none, it copies + * its built-in Node.js Dockerfile into the Docker context. + */ +const DEFAULT_DOCKERFILE_PATHS = ['./Dockerfile', '../Dockerfile']; + +interface PathField { + field: string; + value: string; + caseInsensitive: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Every file-path field the platform resolves relative to `.actor/`; an inline schema object is not a path. */ +function collectPathFields(config: Record): PathField[] { + const fields: PathField[] = []; + const add = (field: string, value: unknown, caseInsensitive = false) => { + // The worker treats an empty string as an unset field. + if (typeof value === 'string' && value !== '') fields.push({ field, value, caseInsensitive }); + }; + + for (const field of LISTED_FILE_FIELDS) add(field, config[field], true); + for (const { field, alias } of SCHEMA_FIELDS) { + if (alias && config[alias]) add(alias, config[alias]); + else add(field, config[field]); + } + if (isRecord(config.storages)) { + for (const field of STORAGE_SCHEMA_FIELDS) add(`storages.${field}`, config.storages[field]); + if (isRecord(config.storages.datasets)) { + for (const [name, value] of Object.entries(config.storages.datasets)) { + add(`storages.datasets.${name}`, value); + } + } + } + return fields; +} + +function listDirectory(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** The entry for `filePath` in a listing of its directory, by the exact name and else by any case. */ +function findEntry(filePath: string): { sameCase?: string; anyCase?: string } { + const wanted = basename(filePath); + const entries = listDirectory(dirname(filePath)); + return { + sameCase: entries.find((entry) => entry === wanted), + anyCase: entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()), + }; +} + +/** The options as every field sees them: real roots and the uploaded files as posix paths. */ +interface PathCheckScope { + actorDir: string; + uploadRoot: string; + uploadedFiles: Set; + dockerContextRoot?: string; +} + +/** + * Applies the platform's rules to one path field of `actor.json`, in order; the first failing rule wins. + * 1. Not absolute, and resolved from `.actor/`, inside the upload root; the platform never sees anything else. The + * platform resolves the value on Linux, where a backslash is part of a name, so this rule reads it that way first. + * 2. No backslashes: on Linux, such a name matches no file a push from another system uploads. + * 3. The file exists in its directory: by any case for `dockerfile`, `readme` and `changelog`, which the + * platform finds by listing the directory, and by the exact name for schema references. A dangling symlink + * does not exist. + * 4. It is a file, not a directory. + * 5. `dockerfile`, set or found by default, lies inside the Docker context, as BuildKit reads it from there. + * 6. It is among the files push uploads, in the same case: a file `.gitignore` or `.actorignore` drops never + * reaches the platform. + * + * Every problem is an error, except for `readme` and `changelog` failing rules 2 (a backslash), 3, or 6: the worker + * does not find such a file and falls back to its default, so these are warnings. An absolute path, a path outside + * the upload root, or a directory fails the build for those fields too. + */ +function checkPathField( + { field, value, caseInsensitive }: PathField, + { actorDir, uploadRoot, uploadedFiles, dockerContextRoot }: PathCheckScope, +): ActorPathProblem | undefined { + const problem = (resolvedPath: string, reason: string, platformFallsBack = false): ActorPathProblem => ({ + field, + value, + resolvedPath, + reason, + severity: platformFallsBack && FALLBACK_FIELDS.has(field) ? 'warning' : 'error', + }); + const absolute = () => problem(value, `is absolute; the path is relative to ${ACTOR_SPECIFICATION_FOLDER}/`); + const resolvedPath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, value); + const outside = () => problem(resolvedPath, `lies outside the uploaded folder ${uploadRoot}`); + + // As the platform reads the value: an absolute or escaping path fails the build, even with a backslash in it. + if (value.startsWith('/')) return absolute(); + const uploadedPath = posix.join(toPosix(relative(uploadRoot, actorDir)), ACTOR_SPECIFICATION_FOLDER, value); + if (uploadedPath === '..' || uploadedPath.startsWith('../')) return outside(); + if (value.includes('\\')) { + return problem(value, 'uses a backslash; the platform resolves paths with forward slashes', true); + } + // As this system reads it, for a Windows drive path. + if (isAbsolute(value)) return absolute(); + if (isOutside(relative(uploadRoot, resolvedPath))) return outside(); + + const { sameCase, anyCase } = findEntry(resolvedPath); + const name = sameCase ?? (caseInsensitive ? anyCase : undefined); + if (!name) { + return problem( + resolvedPath, + anyCase ? `does not exist (the name must match ${anyCase} exactly)` : 'does not exist', + true, + ); + } + + // push reads through a symlink and uploads the content at this path, so the path is judged as it is, not by its + // target; a dangling symlink has no content to upload. + const filePath = join(dirname(resolvedPath), name); + let stats: Stats; + try { + stats = statSync(filePath); + } catch { + return problem(resolvedPath, 'does not exist', true); + } + if (stats.isDirectory()) return problem(resolvedPath, 'is a directory, not a file'); + if (dockerContextRoot && field === 'dockerfile' && isOutside(relative(dockerContextRoot, filePath))) { + return problem(resolvedPath, `lies outside the Docker context ${dockerContextRoot}`); + } + + const uploadedFilePath = toPosix(relative(uploadRoot, filePath)); + if (!uploadedFiles.has(uploadedFilePath)) { + const differentCase = [...uploadedFiles].find((file) => file.toLowerCase() === uploadedFilePath.toLowerCase()); + return problem( + resolvedPath, + differentCase + ? `does not match the case of the uploaded file ${differentCase}` + : 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + true, + ); + } + return undefined; +} + +/** Whether push sends this file, or files inside this directory, so the platform finds it among the uploaded files. */ +function isUploaded(filePath: string, { uploadRoot, uploadedFiles }: PathCheckScope): boolean { + const uploadedPath = toPosix(relative(uploadRoot, filePath)); + return uploadedFiles.has(uploadedPath) || [...uploadedFiles].some((file) => file.startsWith(`${uploadedPath}/`)); +} + +/** + * Without `dockerfile`, the platform takes the first default Dockerfile among the uploaded files, else copies its + * built-in one into the Docker context. A default that `.gitignore` or `.actorignore` drops never reaches the + * platform, so the lookup moves on to the next one. The found default must pass the same rules as a set `dockerfile`. + * The built-in one is the documented default for a context that is the Actor folder itself; in any other context it + * would build files the built-in Dockerfile was never meant for, and silently ignore a Dockerfile the context may hold. + */ +function checkDefaultDockerfile(scope: PathCheckScope & { dockerContextRoot: string }): ActorPathProblem | undefined { + const { actorDir, dockerContextRoot } = scope; + const value = DEFAULT_DOCKERFILE_PATHS.find((candidate) => { + const candidatePath = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, candidate); + const { sameCase, anyCase } = findEntry(candidatePath); + const name = sameCase ?? anyCase; + return name !== undefined && isUploaded(join(dirname(candidatePath), name), scope); + }); + if (value) { + const problem = checkPathField({ field: 'dockerfile', value, caseInsensitive: true }, scope); + return ( + problem && { + ...problem, + value: '', + reason: `is not set, so the platform would use the default ${problem.resolvedPath}, which ${problem.reason}`, + } + ); + } + if (relative(actorDir, dockerContextRoot) === '') return undefined; + return { + field: 'dockerfile', + value: '', + resolvedPath: join(dockerContextRoot, 'Dockerfile'), + reason: + `is not set and push uploads no ${ACTOR_SPECIFICATION_FOLDER}/Dockerfile or Dockerfile in the Actor folder, ` + + `so the platform would build the Docker context ${dockerContextRoot} with its built-in Node.js Dockerfile; ` + + 'add a Dockerfile, or set "dockerfile" to one inside the Docker context', + severity: 'error', + }; +} + +/** + * Applies the platform's rules to every file path in `actor.json` before anything is uploaded: relative to + * `.actor/`, inside the upload root, an existing file, and one that push sends. A symlink counts by its own path, + * as push uploads its content there. With a Docker context and no `dockerfile`, the default Dockerfile is checked too. + */ +export function checkActorPaths(options: ActorPathsOptions): ActorPathProblem[] { + // The Actor folder and the upload root are real paths, as the Docker context resolver works from them, so a + // symlinked Actor folder does not count as an escape. A referenced path, like the Docker context the resolver + // returns, is judged as written below the real Actor folder, the way push lays out the upload. + const scope: PathCheckScope = { + actorDir: realpathSync(options.actorDir), + uploadRoot: realpathSync(options.uploadRoot), + uploadedFiles: new Set(options.uploadedFiles.map(toPosix)), + dockerContextRoot: options.dockerContextRoot, + }; + const pathFields = collectPathFields(options.config); + const problems = pathFields.flatMap((pathField) => checkPathField(pathField, scope) ?? []); + + const { dockerContextRoot } = scope; + if (dockerContextRoot && !pathFields.some(({ field }) => field === 'dockerfile')) { + const problem = checkDefaultDockerfile({ ...scope, dockerContextRoot }); + if (problem) problems.push(problem); + } + return problems; +} + +const PROBLEM_HEADERS: Record = { + error: '.actor/actor.json refers to files the Apify platform could not use:', + warning: + '.actor/actor.json refers to files the Apify platform will not find. ' + + 'The push continues, and the platform falls back to its default README or CHANGELOG:', +}; + +/** The message for the problems of one severity, with a header that says what happens; empty when there are none. */ +export function formatActorPathProblems(problems: ActorPathProblem[], severity: ActorPathSeverity): string { + const selected = problems.filter((problem) => problem.severity === severity); + if (selected.length === 0) return ''; + return [ + PROBLEM_HEADERS[severity], + ...selected.map(({ field, value, resolvedPath, reason }) => + value ? ` "${field}": "${value}" (${resolvedPath}) ${reason}.` : ` "${field}" ${reason}.`, + ), + `Paths are relative to ${ACTOR_SPECIFICATION_FOLDER}/ and must point to files inside the uploaded folder.`, + ].join('\n'); +} diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 1555cc48d..47ee7abe4 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -109,11 +109,11 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown return { kind: 'outside', contextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; } -function isOutside(relativePath: string): boolean { +export function isOutside(relativePath: string): boolean { return isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${sep}`); } -function toPosix(filePath: string): string { +export function toPosix(filePath: string): string { return filePath.split(sep).join('/'); } diff --git a/test/local/commands/push-actor-paths.test.ts b/test/local/commands/push-actor-paths.test.ts new file mode 100644 index 000000000..962dafba3 --- /dev/null +++ b/test/local/commands/push-actor-paths.test.ts @@ -0,0 +1,286 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ACTOR_SOURCE_TYPES } from '@apify/consts'; + +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; +import { cwdCache } from '../../../src/lib/hooks/useActorConfig.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const actName = 'push-actor-paths-actor'; +const ACTOR_ID = 'pAtH1234efGh'; + +const actor = { + id: ACTOR_ID, + name: actName, + // Older than any local file, so the "modified on the platform" check never trips. + modifiedAt: new Date(0), + taggedBuilds: { latest: { buildId: 'build1' } }, +}; +const build = { id: 'build1', actId: ACTOR_ID, buildNumber: '0.0.1', status: 'SUCCEEDED' }; +const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES }; + +const actorGet = vitest.fn(async () => actor); +const versionUpdate = vitest.fn(async (_modifier: unknown) => ({})); +const versionCreate = vitest.fn(async (created: unknown) => created); + +// Stands in for the platform: the Actor and its version exist, and every read and write is recorded. +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: vitest.fn(async () => {}), + getLoggedClientOrThrow: vitest.fn(async () => ({ + baseUrl: 'https://api.apify.com/v2', + actor: () => ({ + get: actorGet, + version: () => ({ get: async () => currentVersion, update: versionUpdate }), + versions: () => ({ create: versionCreate }), + build: async () => build, + }), + build: () => ({ get: async () => build }), + })), +})); + +// The mocked `node:process` copy has no signal handlers; nothing here needs them. +vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ + useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), +})); + +const { beforeAllCalls, afterAllCalls, joinPath, tmpPath } = useTempPath(actName, { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +const { logMessages } = useConsoleSpy(); + +const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); +const { getLoggedClientOrThrow } = await import('../../../src/lib/utils.js'); +// The command sets the exit code on this mocked copy of `node:process`, not on the global one. +const { default: commandProcess } = await import('node:process'); + +async function write(relativePath: string, content: string) { + await mkdir(dirname(joinPath(relativePath)), { recursive: true }); + await writeFile(joinPath(relativePath), content); +} + +async function writeActorConfig(fields: Record) { + await write( + '.actor/actor.json', + JSON.stringify({ actorSpecification: 1, name: actName, version: '0.0', buildTag: 'latest', ...fields }), + ); +} + +// The mocked copy may share the exit code with the real process, so both are restored. +let originalExitCode: typeof process.exitCode; +let originalGlobalExitCode: typeof process.exitCode; + +beforeEach(async () => { + originalExitCode = commandProcess.exitCode; + originalGlobalExitCode = process.exitCode; + actorGet.mockClear(); + versionUpdate.mockClear(); + versionCreate.mockClear(); + vitest.mocked(getLoggedClientOrThrow).mockClear(); + cwdCache.clear(); + await beforeAllCalls(); + + await write('.actor/INPUT_SCHEMA.json', '{}'); + await write('.actorignore', 'Dockerfile.ci\n'); + await write('Dockerfile', 'FROM apify/actor-node:20\n'); + await write('Dockerfile.ci', 'FROM apify/actor-node:20\n'); + await write('src/main.js', 'console.log(1);\n'); +}); + +afterEach(async () => { + commandProcess.exitCode = originalExitCode; + process.exitCode = originalGlobalExitCode; + await afterAllCalls(); +}); + +describe('apify push with file paths in actor.json', () => { + it('stops before logging in or reading or writing the Actor, listing every wrong path', async () => { + await writeActorConfig({ + dockerfile: '../Dockerfile.prod', + readme: '../Dockerfile.ci', + input: '../../INPUT_SCHEMA.json', + }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('.actor/actor.json refers to files the Apify platform could not use:'); + expect(stderr).toContain(`"dockerfile": "../Dockerfile.prod" (${joinPath('Dockerfile.prod')}) does not exist.`); + // The platform falls back from a readme it cannot find, so that one is only a warning. + expect(stderr).toContain('The push continues, and the platform falls back to its default README or CHANGELOG:'); + expect(stderr).toContain( + `"readme": "../Dockerfile.ci" (${joinPath('Dockerfile.ci')}) is not among the files apify push uploads (excluded by .gitignore or .actorignore?).`, + ); + expect(stderr).toContain( + `"input": "../../INPUT_SCHEMA.json" (${joinPath('..', 'INPUT_SCHEMA.json')}) lies outside the uploaded folder ${tmpPath}.`, + ); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(actorGet).not.toHaveBeenCalled(); + expect(versionCreate).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('keeps stdout clean with --json', async () => { + await writeActorConfig({ dockerfile: '../Dockerfile.prod' }); + + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.log).toEqual([]); + expect(logMessages.error.join('\n')).toContain('"dockerfile": "../Dockerfile.prod"'); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('requires the Dockerfile inside a Docker context within the Actor folder', async () => { + await write('docker/Dockerfile', 'FROM apify/actor-node:20\n'); + await writeActorConfig({ dockerContextDir: '../docker', dockerfile: '../Dockerfile' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain( + `"dockerfile": "../Dockerfile" (${joinPath('Dockerfile')}) lies outside the Docker context ${joinPath('docker')}.`, + ); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + ['dockerfile', '../Dockerfile.prod', 'Dockerfile.prod'], + ['input', './missing.json', '.actor/missing.json'], + ])('stops on a missing %s file', async (field, value, resolvedPath) => { + await writeActorConfig({ [field]: value }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain( + `"${field}": "${value}" (${joinPath(resolvedPath)}) does not exist.`, + ); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + ['absolute', '/README.md', 'is absolute'], + ['outside the uploaded folder', '../../README.md', 'lies outside the uploaded folder'], + ])('stops on an %s readme, which fails the build', async (_, readme, reason) => { + await writeActorConfig({ readme }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('.actor/actor.json refers to files the Apify platform could not use:'); + expect(stderr).toContain(`"readme": "${readme}"`); + expect(stderr).toContain(reason); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('warns about a readme only in the Actor folder and pushes, keeping stdout clean with --json', async () => { + // The shape of the js-cypress template: the platform finds no .actor/README.md and falls back to README.md. + await write('README.md', '# Actor\n'); + await writeActorConfig({ readme: './README.md', dockerfile: '../Dockerfile' }); + + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('The push continues, and the platform falls back to its default README or CHANGELOG:'); + expect(stderr).toContain(`"readme": "./README.md" (${joinPath('.actor', 'README.md')}) does not exist.`); + expect(stderr).not.toContain('could not use'); + const stdout = logMessages.log.join('\n'); + expect(stdout).not.toContain('README'); + expect(() => JSON.parse(stdout)).not.toThrow(); + }); + + it('warns about a missing changelog and pushes', async () => { + await writeActorConfig({ changelog: '../CHANGELOG.md' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + expect(logMessages.error.join('\n')).toContain( + `"changelog": "../CHANGELOG.md" (${joinPath('CHANGELOG.md')}) does not exist.`, + ); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); + + it('stops when the default Dockerfile lies outside the Docker context', async () => { + await write('docker/Dockerfile', 'FROM apify/actor-node:20\n'); + await writeActorConfig({ dockerContextDir: '../docker' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain( + `"dockerfile" is not set, so the platform would use the default ${joinPath('Dockerfile')}, which lies outside the Docker context ${joinPath('docker')}.`, + ); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('stops when the platform would build a Docker context with its built-in Dockerfile', async () => { + await rm(joinPath('Dockerfile')); + await write('docker/Dockerfile', 'FROM apify/actor-node:20\n'); + await writeActorConfig({ dockerContextDir: '../docker' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain( + `"dockerfile" is not set and push uploads no .actor/Dockerfile or Dockerfile in the Actor folder, so the platform would build the Docker context ${joinPath('docker')} with its built-in Node.js Dockerfile`, + ); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it.each([ + ['the Actor folder as the Docker context', { dockerContextDir: '..' }], + ['no Docker context', {}], + ])('pushes without any Dockerfile and with %s', async (_, fields) => { + await rm(joinPath('Dockerfile')); + await writeActorConfig(fields); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(logMessages.error.join('\n')).not.toContain('actor.json refers to files'); + expect(versionUpdate).toHaveBeenCalledTimes(1); + }); + + it('pushes with a default .actor/Dockerfile inside the Docker context', async () => { + await write('.actor/Dockerfile', 'FROM apify/actor-node:20\n'); + await writeActorConfig({ dockerContextDir: '.' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + }); + + it('pushes as usual when every path points to an uploaded file', async () => { + await writeActorConfig({ dockerfile: '../dockerfile', input: './INPUT_SCHEMA.json' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const modifier = versionUpdate.mock.calls[0][0] as { sourceFiles: { name: string }[] }; + expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual([ + '.actor/INPUT_SCHEMA.json', + '.actor/actor.json', + '.actorignore', + 'Dockerfile', + 'src/main.js', + ]); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); +}); diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index d1813d373..d2148be35 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -170,9 +170,15 @@ describe('apify push of a monorepo Actor', () => { }); it.each([ - ['lies inside it', { dockerContextDir: '../src' }], - ['is not set', {}], - ])('pushes the Actor folder as usual when the Docker context %s', async (_, fields) => { + ['is the Actor folder itself', { dockerContextDir: '..' }, []], + [ + 'lies inside it, with a Dockerfile there', + { dockerContextDir: '../src', dockerfile: '../src/Dockerfile' }, + ['src/Dockerfile'], + ], + ['is not set', {}, []], + ])('pushes the Actor folder as usual when the Docker context %s', async (_, fields, extraFiles) => { + for (const file of extraFiles) await write(`${ACTOR_PATH}/${file}`, 'FROM apify/actor-node:20\n'); await writeActorConfig(fields); await testRunCommand(ActorsPushCommand, {}); @@ -182,7 +188,9 @@ describe('apify push of a monorepo Actor', () => { expect(versionUpdate).toHaveBeenCalledTimes(1); const modifier = versionUpdate.mock.calls[0][0] as { sourceType: string; sourceFiles: { name: string }[] }; expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); - expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual(['.actor/actor.json', 'src/index.ts']); + expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual( + ['.actor/actor.json', 'src/index.ts', ...extraFiles].sort(), + ); expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); }); diff --git a/test/local/lib/actor-paths.test.ts b/test/local/lib/actor-paths.test.ts new file mode 100644 index 000000000..209d67bda --- /dev/null +++ b/test/local/lib/actor-paths.test.ts @@ -0,0 +1,528 @@ +import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { type ActorPathProblem, checkActorPaths, formatActorPathProblems } from '../../../src/lib/actor-paths.js'; +import { resolveDockerContext } from '../../../src/lib/docker-context.js'; +import { getActorLocalFilePaths } from '../../../src/lib/utils.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const ACTOR_PATH = 'actors/x'; + +describe('checkActorPaths', () => { + const { tmpPath, beforeAllCalls, afterAllCalls } = useTempPath('actor-paths-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + // The checker works from real paths, so the expectations do too. + let root: string; + let actorDir: string; + let uploadedFiles: string[]; + + const write = (relativePath: string, content = 'content') => { + mkdirSync(dirname(join(root, relativePath)), { recursive: true }); + writeFileSync(join(root, relativePath), content); + }; + + // The Actor folder is what an ordinary push uploads. + const check = ( + config: Record, + options: { uploadRoot?: string; uploadedFiles?: string[]; dockerContextRoot?: string } = {}, + ) => checkActorPaths({ actorDir, uploadRoot: actorDir, uploadedFiles, config, ...options }); + + beforeAll(async () => { + await beforeAllCalls(); + root = realpathSync(tmpPath); + actorDir = join(root, ACTOR_PATH); + write(`${ACTOR_PATH}/.actor/actor.json`, '{}'); + write(`${ACTOR_PATH}/.actor/INPUT_SCHEMA.json`, '{}'); + write(`${ACTOR_PATH}/.actor/README.md`); + write(`${ACTOR_PATH}/.actor/CHANGELOG.md`); + write(`${ACTOR_PATH}/.actorignore`, 'Dockerfile.ci\n'); + write(`${ACTOR_PATH}/Dockerfile`, 'FROM apify/actor-node:20\n'); + write(`${ACTOR_PATH}/Dockerfile.ci`, 'FROM apify/actor-node:20\n'); + write(`${ACTOR_PATH}/src/index.js`); + write('shared/Dockerfile', 'FROM apify/actor-node:20\n'); + write('shared/input_schema.json', '{}'); + symlinkSync(join(root, 'shared', 'input_schema.json'), join(actorDir, '.actor', 'linked_schema.json')); + symlinkSync(join(root, 'shared', 'Dockerfile'), join(actorDir, 'Dockerfile.shared')); + uploadedFiles = await getActorLocalFilePaths(actorDir); + symlinkSync(join(root, 'shared', 'missing.json'), join(actorDir, '.actor', 'dangling.json')); + symlinkSync(actorDir, join(root, 'linked-actor')); + symlinkSync(join(root, 'shared'), join(actorDir, 'sym')); + }); + + afterAll(async () => { + await afterAllCalls(); + }); + + it('passes files inside the upload root, next to actor.json or above it', () => { + expect(check({ input: './INPUT_SCHEMA.json', readme: './README.md', dockerfile: '../Dockerfile' })).toEqual([]); + expect(check({ input: 'INPUT_SCHEMA.json' })).toEqual([]); + }); + + it('does nothing for a config without path fields', () => { + expect(check({ name: 'x', version: '0.0', environmentVariables: { A: '1' } })).toEqual([]); + expect(check({})).toEqual([]); + }); + + it('skips inline schema objects', () => { + expect( + check({ + input: { title: 'Input', type: 'object', properties: {} }, + storages: { dataset: { fields: {} }, datasets: { main: { fields: {} } } }, + }), + ).toEqual([]); + }); + + it('rejects a path above the upload root', () => { + expect(check({ dockerfile: '../../../shared/Dockerfile' })).toEqual([ + { + field: 'dockerfile', + value: '../../../shared/Dockerfile', + resolvedPath: join(root, 'shared', 'Dockerfile'), + reason: `lies outside the uploaded folder ${actorDir}`, + severity: 'error', + }, + ]); + }); + + it('skips an empty string, which the platform treats as an unset field', () => { + expect(check({ dockerfile: '', input: '', storages: { dataset: '', datasets: { main: '' } } })).toEqual([]); + }); + + it('rejects a backslash before resolving anything', () => { + expect(check({ dockerfile: '..\\Dockerfile' })).toEqual([ + { + field: 'dockerfile', + value: '..\\Dockerfile', + resolvedPath: '..\\Dockerfile', + reason: 'uses a backslash; the platform resolves paths with forward slashes', + severity: 'error', + }, + ]); + }); + + it('rejects an absolute path', () => { + const value = '/x'; + + expect(check({ input: value })).toEqual([ + { + field: 'input', + value, + resolvedPath: value, + reason: 'is absolute; the path is relative to .actor/', + severity: 'error', + }, + ]); + }); + + it('rejects a missing file', () => { + expect(check({ input: './missing.json' })).toEqual([ + { + field: 'input', + value: './missing.json', + resolvedPath: join(actorDir, '.actor', 'missing.json'), + reason: 'does not exist', + severity: 'error', + }, + ]); + }); + + it('rejects a directory', () => { + expect(check({ dockerfile: '../src' })).toEqual([ + { + field: 'dockerfile', + value: '../src', + resolvedPath: join(actorDir, 'src'), + reason: 'is a directory, not a file', + severity: 'error', + }, + ]); + }); + + it('rejects a file push does not upload', () => { + expect(check({ dockerfile: '../Dockerfile.ci' })).toEqual([ + { + field: 'dockerfile', + value: '../Dockerfile.ci', + resolvedPath: join(actorDir, 'Dockerfile.ci'), + reason: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + severity: 'error', + }, + ]); + }); + + it('rejects a file whose case differs from the uploaded one', () => { + // A case-insensitive file system finds README.md, but the Git index lists it as readme.md. + expect(check({ readme: './README.md' }, { uploadedFiles: ['.actor/readme.md'] })).toEqual([ + { + field: 'readme', + value: './README.md', + resolvedPath: join(actorDir, '.actor', 'README.md'), + reason: 'does not match the case of the uploaded file .actor/readme.md', + severity: 'warning', + }, + ]); + }); + + it('judges a symlink by its own path, as push uploads its content there', () => { + // The link points outside the upload root; push reads through it and uploads the content at the link's path. + expect(uploadedFiles).toContain('.actor/linked_schema.json'); + expect(check({ input: './linked_schema.json' })).toEqual([]); + + const withoutLink = uploadedFiles.filter((file) => file !== '.actor/linked_schema.json'); + expect(check({ input: './linked_schema.json' }, { uploadedFiles: withoutLink })).toEqual([ + { + field: 'input', + value: './linked_schema.json', + resolvedPath: join(actorDir, '.actor', 'linked_schema.json'), + reason: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + severity: 'error', + }, + ]); + }); + + it('rejects a dangling symlink as missing', () => { + expect(check({ input: './dangling.json' }, { uploadedFiles: [...uploadedFiles, '.actor/dangling.json'] })).toEqual([ + { + field: 'input', + value: './dangling.json', + resolvedPath: join(actorDir, '.actor', 'dangling.json'), + reason: 'does not exist', + severity: 'error', + }, + ]); + }); + + it('judges a symlinked Dockerfile against the Docker context by its own path', () => { + // The link's target lies outside the context, but push uploads its content at the link's path inside it. + expect(uploadedFiles).toContain('Dockerfile.shared'); + expect(check({ dockerfile: '../Dockerfile.shared' }, { dockerContextRoot: actorDir })).toEqual([]); + }); + + it('accepts a Dockerfile in a context within a symlinked Actor folder', () => { + const linkedActorDir = join(root, 'linked-actor'); + const dockerContext = resolveDockerContext(linkedActorDir, '..'); + expect(dockerContext).toEqual({ kind: 'within', contextRoot: actorDir }); + + expect( + checkActorPaths({ + actorDir: linkedActorDir, + uploadRoot: linkedActorDir, + uploadedFiles, + config: { dockerfile: '../Dockerfile' }, + dockerContextRoot: actorDir, + }), + ).toEqual([]); + }); + + it('accepts a Dockerfile in a Docker context reached through a symlink inside the Actor folder', async () => { + // `sym` links to the shared folder outside the Actor; push uploads it as a normal folder `sym` inside. + const dockerContext = resolveDockerContext(actorDir, '../sym'); + expect(dockerContext).toEqual({ kind: 'within', contextRoot: join(actorDir, 'sym') }); + + expect( + checkActorPaths({ + actorDir, + uploadRoot: actorDir, + uploadedFiles: await getActorLocalFilePaths(actorDir), + config: { dockerContextDir: '../sym', dockerfile: '../sym/Dockerfile' }, + dockerContextRoot: join(actorDir, 'sym'), + }), + ).toEqual([]); + }); + + it('matches the name of dockerfile, readme and changelog case-insensitively, and a schema exactly', () => { + expect(check({ dockerfile: '../dockerfile', readme: './readme.MD', changelog: './changelog.md' })).toEqual([]); + expect(check({ input: './input_schema.json' })).toEqual([ + { + field: 'input', + value: './input_schema.json', + resolvedPath: join(actorDir, '.actor', 'input_schema.json'), + reason: 'does not exist (the name must match INPUT_SCHEMA.json exactly)', + severity: 'error', + }, + ]); + }); + + it('checks every string-valued schema reference', () => { + expect( + check({ + output: './output.json', + webServerSchema: './openapi.json', + storages: { keyValueStore: './kvs.json', dataset: './dataset.json', datasets: { main: './main.json' } }, + }).map((problem) => problem.field), + ).toEqual(['output', 'webServerSchema', 'storages.keyValueStore', 'storages.dataset', 'storages.datasets.main']); + }); + + it('checks inputSchema and outputSchema, which win over input and output', () => { + expect(check({ inputSchema: './in.json', outputSchema: './out.json' }).map((problem) => problem.field)).toEqual([ + 'inputSchema', + 'outputSchema', + ]); + // The worker copies a set alias over its base key, so the base key is never resolved. + expect(check({ input: './missing.json', inputSchema: './INPUT_SCHEMA.json' })).toEqual([]); + expect(check({ output: './missing.json', outputSchema: { type: 'object' } })).toEqual([]); + expect(check({ input: './missing.json', inputSchema: '' }).map((problem) => problem.field)).toEqual(['input']); + }); + + it('warns about a readme or changelog the platform falls back from', () => { + const severities = (config: Record) => + check(config, { uploadedFiles: [...uploadedFiles, '.actor/dangling.json'] }).map( + ({ field, reason, severity }) => ({ field, reason, severity }), + ); + + expect(severities({ readme: '../README.md', changelog: '../CHANGELOG.md' })).toEqual([ + { field: 'readme', reason: 'does not exist', severity: 'warning' }, + { field: 'changelog', reason: 'does not exist', severity: 'warning' }, + ]); + expect(severities({ readme: './dangling.json' })).toEqual([ + { field: 'readme', reason: 'does not exist', severity: 'warning' }, + ]); + expect(severities({ changelog: '../Dockerfile.ci' })).toEqual([ + { + field: 'changelog', + reason: 'is not among the files apify push uploads (excluded by .gitignore or .actorignore?)', + severity: 'warning', + }, + ]); + expect(severities({ readme: '.\\README.md' })).toEqual([ + { + field: 'readme', + reason: 'uses a backslash; the platform resolves paths with forward slashes', + severity: 'warning', + }, + ]); + }); + + it('rejects a readme or changelog that fails the build', () => { + expect( + check({ readme: '/README.md', changelog: '../../../shared/Dockerfile' }).map(({ field, severity }) => ({ + field, + severity, + })), + ).toEqual([ + { field: 'readme', severity: 'error' }, + { field: 'changelog', severity: 'error' }, + ]); + expect(check({ readme: '../src' }).map(({ reason, severity }) => ({ reason, severity }))).toEqual([ + { reason: 'is a directory, not a file', severity: 'error' }, + ]); + }); + + it('reads a backslash as the platform does, so an absolute or escaping readme still fails the build', () => { + expect( + check({ readme: '/docs\\README.md', changelog: '../../../docs\\CHANGELOG.md' }).map( + ({ field, reason, severity }) => ({ field, reason, severity }), + ), + ).toEqual([ + { field: 'readme', reason: 'is absolute; the path is relative to .actor/', severity: 'error' }, + { field: 'changelog', reason: `lies outside the uploaded folder ${actorDir}`, severity: 'error' }, + ]); + }); + + it('rejects a missing dockerfile or schema, which the platform does not recover from', () => { + expect( + check({ dockerfile: '../Dockerfile.prod', input: './missing.json' }).map(({ field, severity }) => ({ + field, + severity, + })), + ).toEqual([ + { field: 'dockerfile', severity: 'error' }, + { field: 'input', severity: 'error' }, + ]); + }); + + it('requires dockerfile inside the Docker context when one is given', () => { + const monorepoFiles = [`${ACTOR_PATH}/Dockerfile`, `${ACTOR_PATH}/.actor/actor.json`, 'shared/Dockerfile']; + const monorepo = { uploadRoot: root, uploadedFiles: monorepoFiles, dockerContextRoot: join(root, 'shared') }; + + expect(check({ dockerfile: '../../../shared/Dockerfile' }, monorepo)).toEqual([]); + expect(check({ dockerfile: '../Dockerfile' }, monorepo)).toEqual([ + { + field: 'dockerfile', + value: '../Dockerfile', + resolvedPath: join(actorDir, 'Dockerfile'), + reason: `lies outside the Docker context ${join(root, 'shared')}`, + severity: 'error', + }, + ]); + expect(check({ dockerfile: '../Dockerfile' }, { ...monorepo, dockerContextRoot: root })).toEqual([]); + }); +}); + +describe('checkActorPaths without dockerfile', () => { + const { tmpPath, beforeAllCalls, afterAllCalls } = useTempPath('actor-paths-default-dockerfile-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + let root: string; + + beforeAll(async () => { + await beforeAllCalls(); + root = realpathSync(tmpPath); + }); + + afterAll(async () => { + await afterAllCalls(); + }); + + // A fresh Actor folder with the given files, checked as push checks it: the folder is the upload root. + const checkActor = async ( + name: string, + files: string[], + dockerContextDir: string | undefined, + config: Record = {}, + ) => { + const actorDir = join(root, name); + for (const file of ['.actor/actor.json', ...files]) { + mkdirSync(dirname(join(actorDir, file)), { recursive: true }); + writeFileSync(join(actorDir, file), 'FROM apify/actor-node:20\n'); + } + const dockerContext = resolveDockerContext(actorDir, dockerContextDir); + const problems = checkActorPaths({ + actorDir, + uploadRoot: actorDir, + uploadedFiles: await getActorLocalFilePaths(actorDir), + config: { dockerContextDir, ...config }, + dockerContextRoot: dockerContext.kind === 'within' ? dockerContext.contextRoot : undefined, + }); + return { actorDir, problems }; + }; + + it('rejects a default Dockerfile outside the Docker context', async () => { + const { actorDir, problems } = await checkActor('outside', ['Dockerfile', 'docker/Dockerfile'], '../docker'); + + expect(problems).toEqual([ + { + field: 'dockerfile', + value: '', + resolvedPath: join(actorDir, 'Dockerfile'), + reason: `is not set, so the platform would use the default ${join(actorDir, 'Dockerfile')}, which lies outside the Docker context ${join(actorDir, 'docker')}`, + severity: 'error', + }, + ]); + }); + + it('rejects the built-in Dockerfile in a Docker context other than the Actor folder', async () => { + const { actorDir, problems } = await checkActor('built-in', ['docker/Dockerfile'], '../docker'); + + expect(problems).toEqual([ + { + field: 'dockerfile', + value: '', + resolvedPath: join(actorDir, 'docker', 'Dockerfile'), + reason: expect.stringContaining( + `so the platform would build the Docker context ${join(actorDir, 'docker')} with its built-in Node.js Dockerfile`, + ), + severity: 'error', + }, + ]); + }); + + it('accepts the built-in Dockerfile when the Docker context is the Actor folder', async () => { + expect((await checkActor('actor-context', ['src/main.js'], '..')).problems).toEqual([]); + }); + + it('does nothing without a Docker context', async () => { + expect((await checkActor('no-context', ['src/main.js'], undefined)).problems).toEqual([]); + expect((await checkActor('no-context-outside', ['Dockerfile', 'docker/Dockerfile'], undefined)).problems).toEqual( + [], + ); + }); + + it('accepts a default .actor/Dockerfile inside the Docker context, found by any case and before Dockerfile', async () => { + expect((await checkActor('in-actor-folder', ['.actor/dockerfile', 'Dockerfile'], '.')).problems).toEqual([]); + }); + + it('skips a default Dockerfile push does not upload, as the platform never sees it', async () => { + // `.actor/` is always uploaded, so only the Dockerfile in the Actor folder can be left out. + for (const name of ['ignored-actor-context', 'ignored-docker-context']) { + mkdirSync(join(root, name), { recursive: true }); + writeFileSync(join(root, name, '.actorignore'), 'Dockerfile\n'); + } + + expect((await checkActor('ignored-actor-context', ['Dockerfile'], '..')).problems).toEqual([]); + const { actorDir, problems } = await checkActor( + 'ignored-docker-context', + ['Dockerfile', 'docker/main.js'], + '../docker', + ); + expect(problems).toEqual([ + { + field: 'dockerfile', + value: '', + resolvedPath: join(actorDir, 'docker', 'Dockerfile'), + reason: expect.stringContaining('is not set and push uploads no .actor/Dockerfile or Dockerfile'), + severity: 'error', + }, + ]); + }); + + it('leaves a set dockerfile to the usual rules', async () => { + const { problems } = await checkActor('set', ['Dockerfile', 'docker/Dockerfile'], '../docker', { + dockerfile: '../docker/Dockerfile', + }); + + expect(problems).toEqual([]); + }); +}); + +describe('formatActorPathProblems', () => { + const problems: ActorPathProblem[] = [ + { + field: 'dockerfile', + value: '../Dockerfile.prod', + resolvedPath: '/actor/Dockerfile.prod', + reason: 'does not exist', + severity: 'error', + }, + { + field: 'readme', + value: './README.md', + resolvedPath: '/actor/.actor/README.md', + reason: 'does not exist', + severity: 'warning', + }, + { field: 'input', value: '/schema.json', resolvedPath: '/schema.json', reason: 'is absolute', severity: 'error' }, + { + field: 'dockerfile', + value: '', + resolvedPath: '/actor/Dockerfile', + reason: 'is not set, so the platform would use the default /actor/Dockerfile', + severity: 'error', + }, + ]; + + it('lists the errors with their field, value, resolved path and reason', () => { + expect(formatActorPathProblems(problems, 'error')).toBe( + [ + '.actor/actor.json refers to files the Apify platform could not use:', + ' "dockerfile": "../Dockerfile.prod" (/actor/Dockerfile.prod) does not exist.', + ' "input": "/schema.json" (/schema.json) is absolute.', + ' "dockerfile" is not set, so the platform would use the default /actor/Dockerfile.', + 'Paths are relative to .actor/ and must point to files inside the uploaded folder.', + ].join('\n'), + ); + }); + + it('lists the warnings under a header that says the push continues', () => { + expect(formatActorPathProblems(problems, 'warning')).toBe( + [ + '.actor/actor.json refers to files the Apify platform will not find. The push continues, and the platform falls back to its default README or CHANGELOG:', + ' "readme": "./README.md" (/actor/.actor/README.md) does not exist.', + 'Paths are relative to .actor/ and must point to files inside the uploaded folder.', + ].join('\n'), + ); + }); + + it('returns an empty string when no problem has the severity', () => { + expect(formatActorPathProblems(problems.slice(0, 1), 'warning')).toBe(''); + }); +});