diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 4d50dbb1d..cd5d800a4 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,8 +1,10 @@ -import { readFileSync, statSync, unlinkSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { readFileSync, realpathSync, statSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; import process from 'node:process'; -import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; +import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyApiError } from 'apify-client'; import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; @@ -20,7 +22,14 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { getConsoleUrl } from '../../lib/console-url.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; -import { monorepoUnsupportedMessage, resolveDockerContext } from '../../lib/docker-context.js'; +import { + getContextFilePaths, + isOutside, + monorepoUnsupportedMessage, + readGitProvenance, + resolveDockerContext, + toPosix, +} from '../../lib/docker-context.js'; import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; @@ -38,7 +47,6 @@ import { printJsonToStdout, } from '../../lib/utils.js'; -const TEMP_ZIP_FILE_NAME = 'temp_file.zip'; const DEFAULT_RUN_OPTIONS = { build: 'latest', memoryMbytes: 4096, @@ -155,6 +163,31 @@ const confirmGitSourceSwitch = async ({ }); }; +/** A platform without the `actorPath` version field rejects the key with a schema error. */ +const rejectsActorPath = (err: unknown) => { + const { statusCode, message } = err as ApifyApiError; + return statusCode === 400 && typeof message === 'string' && message.includes('actorPath'); +}; + +/** + * The platform's multi-file and zip sources carry files only: a link to a file goes as that file, a link to a + * directory inside the upload as copies of the uploaded files under its target, and a link to nothing is left out. + */ +const resolveLinksForUpload = (root: string, files: string[]) => { + const stats = new Map(files.map((file) => [file, statSync(join(root, file), { throwIfNoEntry: false })])); + return files.flatMap((file) => { + const fileStats = stats.get(file); + if (fileStats?.isFile()) return [file]; + if (!fileStats?.isDirectory()) return []; + const target = relative(realpathSync(root), realpathSync(join(root, file))); + if (isOutside(target)) return []; + const prefix = `${toPosix(target)}/`; + return files + .filter((other) => other.startsWith(prefix) && stats.get(other)?.isFile()) + .map((other) => `${file}/${other.slice(prefix.length)}`); + }); +}; + export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; @@ -290,25 +323,30 @@ export class ActorsPushCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); - // A monorepo Actor builds from a Docker context above its folder, which a push of that folder cannot carry. + // A monorepo Actor builds from a Docker context above its folder, so the push uploads its repository + // (or, outside Git, the context) and tells the platform where the Actor lies in it. const dockerContext = resolveDockerContext(cwd, actorConfig?.dockerContextDir); if (dockerContext.kind === 'invalid') { error({ message: dockerContext.message }); process.exitCode = CommandExitCodes.InvalidActorJson; return; } + let uploadRoot = cwd; + let uploadedFiles = filePathsToPush; + let actorPath: string | undefined; if (dockerContext.kind === 'context') { - error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); - process.exitCode = CommandExitCodes.NotImplemented; - return; + uploadRoot = dockerContext.sourceRoot; + uploadedFiles = resolveLinksForUpload(uploadRoot, (await getContextFilePaths(uploadRoot)).map(toPosix)); + actorPath = dockerContext.actorPath; } // 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, + uploadRoot, + uploadedFiles, config: actorConfig, + dockerContextRoot: dockerContext.kind === 'context' ? dockerContext.contextRoot : undefined, }); if (pathProblems.length > 0) { error({ message: formatActorPathProblems(pathProblems) }); @@ -316,6 +354,17 @@ export class ActorsPushCommand extends ApifyCommand { return; } + if (actorPath && readGitProvenance(uploadRoot).dirty) { + warning({ + message: `The repository ${uploadRoot} has uncommitted changes; the push uploads the files as they are.`, + }); + } + + const refuseMonorepoPush = () => { + error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); + process.exitCode = CommandExitCodes.NotImplemented; + }; + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; @@ -368,6 +417,7 @@ export class ActorsPushCommand extends ApifyCommand { // TODO: export enum from apify-client sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES as never, sourceFiles: [], + ...(actorPath ? { actorPath } : {}), }, ], }; @@ -377,7 +427,13 @@ export class ActorsPushCommand extends ApifyCommand { newActor.actorStandby = { isEnabled: true }; } - actor = await apifyClient.actors().create(newActor); + try { + actor = await apifyClient.actors().create(newActor); + } catch (err) { + if (!rejectsActorPath(err)) throw err; + refuseMonorepoPush(); + return; + } actorId = actor.id; isActorCreatedNow = true; info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` }); @@ -393,14 +449,14 @@ export class ActorsPushCommand extends ApifyCommand { info({ message: `Deploying Actor '${actorConfig!.name}' to Apify.` }); - const filesSize = await sumFilesSizeInBytes(filePathsToPush, cwd); + const filesSize = await sumFilesSizeInBytes(uploadedFiles, uploadRoot); - if (filesSize < MAX_MULTIFILE_BYTES && !isActorCreatedNow) { + if ((filesSize < MAX_MULTIFILE_BYTES || actorPath) && !isActorCreatedNow) { const client = await actorClient.get(); // Check when was files modified last - const mostRecentModifiedFileMs = filePathsToPush.reduce((modifiedMs, filePath) => { - const { mtimeMs, ctimeMs } = statSync(join(cwd, filePath)); + const mostRecentModifiedFileMs = uploadedFiles.reduce((modifiedMs, filePath) => { + const { mtimeMs, ctimeMs } = statSync(join(uploadRoot, filePath)); // Sometimes it's possible mtimeMs is some messed up value (like 2000/01/01 midnight), then we want to check created if it's newer const fileModifiedMs = mtimeMs > ctimeMs ? mtimeMs : ctimeMs; @@ -443,24 +499,28 @@ Skipping push. Use --force to override.`, let sourceFiles; let tarballUrl; if (filesSize < MAX_MULTIFILE_BYTES) { - sourceFiles = await createSourceFiles(filePathsToPush, cwd); + sourceFiles = await createSourceFiles(uploadedFiles, uploadRoot); sourceType = ACTOR_SOURCE_TYPES.SOURCE_FILES; } else { // Create zip run({ message: 'Zipping Actor files' }); - await createActZip(TEMP_ZIP_FILE_NAME, filePathsToPush, cwd); + const zipPath = join(tmpdir(), `apify-push-${randomUUID()}.zip`); + await createActZip(zipPath, uploadedFiles, uploadRoot); // Upload it to Apify.keyValueStores const store = await apifyClient.keyValueStores().getOrCreate(`actor-${actorId}-source`); const key = `version-${version}.zip`; - const buffer = readFileSync(TEMP_ZIP_FILE_NAME); - await apifyClient.keyValueStore(store.id).setRecord({ - key, - // TODO: fix this type too - value: buffer as never, - contentType: 'application/zip', - }); - unlinkSync(TEMP_ZIP_FILE_NAME); + try { + const buffer = readFileSync(zipPath); + await apifyClient.keyValueStore(store.id).setRecord({ + key, + // TODO: fix this type too + value: buffer as never, + contentType: 'application/zip', + }); + } finally { + unlinkSync(zipPath); + } const tempTarballUrl = new URL( `${apifyClient.baseUrl}/key-value-stores/${store.id}/records/${key}?disableRedirect=true`, ); @@ -493,27 +553,42 @@ Skipping push. Use --force to override.`, const applyEnvVarsToBuild = this.flags.applyEnvVarsToBuild ?? (actorConfig!.applyEnvVarsToBuild as boolean | undefined); - if (actorCurrentVersion) { - const actorVersionModifier = { tarballUrl, sourceFiles, buildTag, sourceType, envVars, applyEnvVarsToBuild }; - // TODO: fix this type too -.- - await actorClient.version(version).update(actorVersionModifier as never); - run({ message: `Updated version ${version} for Actor ${actor.name}.` }); - } else { - const actorNewVersion = { - versionNumber: version, - tarballUrl, - sourceFiles, - buildTag, - sourceType, - envVars, - applyEnvVarsToBuild, - }; + const actorVersionSource = { tarballUrl, sourceFiles, sourceType, ...(actorPath ? { actorPath } : {}) }; + try { + if (actorCurrentVersion) { + const actorVersionModifier = { ...actorVersionSource, buildTag, envVars, applyEnvVarsToBuild }; + // TODO: fix this type too -.- + await actorClient.version(version).update(actorVersionModifier as never); + run({ message: `Updated version ${version} for Actor ${actor.name}.` }); + } else { + const actorNewVersion = { + versionNumber: version, + ...actorVersionSource, + buildTag, + envVars, + applyEnvVarsToBuild, + }; - await actorClient.versions().create({ - ...actorNewVersion, - } as never); + await actorClient.versions().create({ + ...actorNewVersion, + } as never); - run({ message: `Created version ${version} for Actor ${actor.name}.` }); + run({ message: `Created version ${version} for Actor ${actor.name}.` }); + } + } catch (err) { + if (!rejectsActorPath(err)) throw err; + if (isActorCreatedNow) { + try { + await actorClient.delete(); + info({ message: `Deleted the just created Actor ${actor.name} again.` }); + } catch (deleteErr) { + warning({ + message: `Could not delete the just created Actor ${actor.name}: ${(deleteErr as Error).message}`, + }); + } + } + refuseMonorepoPush(); + return; } // Sync standby mode on existing actors with actor.json diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 6d3eab22d..2772298b3 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -1,8 +1,9 @@ import { execFileSync } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { existsSync, symlinkSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; -import { ACTOR_SOURCE_TYPES } from '@apify/consts'; +import { ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { CommandExitCodes } from '../../../src/lib/consts.js'; @@ -25,10 +26,27 @@ const actor = { 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); +interface VersionPayload { + sourceType: string; + sourceFiles?: { name: string }[]; + tarballUrl?: string; + actorPath?: string; +} + +const actorGet = vitest.fn(async (): Promise => actor); +const actorDelete = vitest.fn(async () => {}); +const actorsCreate = vitest.fn(async (_created: unknown) => actor); const versionUpdate = vitest.fn(async (_modifier: unknown) => ({})); const versionCreate = vitest.fn(async (created: unknown) => created); -const keyValueStoreGetOrCreate = vitest.fn(async () => ({ id: 'store1' })); +const keyValueStoreGetOrCreate = vitest.fn(async () => ({ id: 'store1', urlSigningSecretKey: 'secret' })); +const setRecord = vitest.fn(async (_record: unknown) => {}); + +// What apify-client throws when a platform without the `actorPath` version field rejects the key. +const actorPathRejected = () => + Object.assign(new Error('Invalid actor version: actorPath is not allowed by the schema'), { + statusCode: 400, + type: 'invalid-request', + }); // Stands in for the platform: the Actor and its version exist, and every write is recorded. vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ @@ -40,20 +58,43 @@ vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ token: 'my-token', actor: () => ({ get: actorGet, + delete: actorDelete, version: () => ({ get: async () => currentVersion, update: versionUpdate }), versions: () => ({ create: versionCreate }), build: async () => build, }), + actors: () => ({ create: actorsCreate }), keyValueStores: () => ({ getOrCreate: keyValueStoreGetOrCreate }), + keyValueStore: () => ({ setRecord }), build: () => ({ get: async () => build }), })), })); +// Creating an Actor looks its template up in the manifest; nothing here needs the network. +vitest.mock('@apify/actor-templates', () => ({ fetchManifest: async () => ({ templates: [] }) })); + // The mocked `node:process` copy has no signal handlers; nothing here needs them. vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), })); +// With `gitAvailable` off, every git call fails as it does without git, so the fixture counts as outside Git +// even though it lives inside this repository's checkout. +const gitState = vitest.hoisted(() => ({ gitAvailable: true })); +vitest.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + const unavailable = () => { + throw new Error('not a git repository'); + }; + return { + ...original, + execSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execSync(...args) : unavailable()) as typeof original.execSync, + execFileSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execFileSync(...args) : unavailable()) as typeof original.execFileSync, + }; +}); + const { beforeAllCalls, afterAllCalls, joinPath, tmpPath, forceNewCwd } = useTempPath(actName, { create: true, remove: true, @@ -83,14 +124,33 @@ function git(...args: string[]) { execFileSync('git', args, { cwd: tmpPath, stdio: 'ignore' }); } +function versionPayload(mock: typeof versionUpdate | typeof actorsCreate) { + expect(mock).toHaveBeenCalledTimes(1); + return mock.mock.calls[0][0] as VersionPayload; +} + +function expectRefusal() { + expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain('apify push does not support such Actors on the Apify platform yet'); + expect(stderr).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(logMessages.log.join('\n')).not.toContain('Apify push result'); +} + let originalExitCode: typeof process.exitCode; +let originalGlobalExitCode: typeof process.exitCode; beforeEach(async () => { originalExitCode = commandProcess.exitCode; + originalGlobalExitCode = process.exitCode; + gitState.gitAvailable = true; actorGet.mockClear(); + actorDelete.mockClear(); + actorsCreate.mockClear(); versionUpdate.mockClear(); versionCreate.mockClear(); keyValueStoreGetOrCreate.mockClear(); + setRecord.mockClear(); cwdCache.clear(); await beforeAllCalls(); @@ -111,34 +171,193 @@ beforeEach(async () => { afterEach(async () => { commandProcess.exitCode = originalExitCode; + process.exitCode = originalGlobalExitCode; await afterAllCalls(); }); describe('apify push of a monorepo Actor', () => { - it('stops before touching the platform, pointing at the tracking issue', async () => { + it('uploads the repository root as source files with the Actor folder as actorPath', async () => { await testRunCommand(ActorsPushCommand, {}); - expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); - const stderr = logMessages.error.join('\n'); - expect(stderr).toContain( - `Actor ${actName} sets "dockerContextDir" in .actor/actor.json, so it builds from a Docker context outside its own folder (a monorepo Actor).`, + expect(commandProcess.exitCode).toBeFalsy(); + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); + expect(modifier.actorPath).toBe(ACTOR_PATH); + expect(modifier.tarballUrl).toBeUndefined(); + const names = modifier.sourceFiles!.map((file) => file.name); + expect(names).toEqual( + expect.arrayContaining([ + `${ACTOR_PATH}/.actor/actor.json`, + `${ACTOR_PATH}/src/index.ts`, + 'package.json', + 'shared/TypeScript_Dockerfile', + 'packages/typescript-utils/src/index.ts', + ]), ); - expect(stderr).toContain('apify push does not support such Actors on the Apify platform yet'); - expect(stderr).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); - expect(stderr).toContain("set the Actor's source in Apify Console to the Git repository with the folder"); - expect(actorGet).not.toHaveBeenCalled(); - expect(versionCreate).not.toHaveBeenCalled(); - expect(versionUpdate).not.toHaveBeenCalled(); + expect(names.some((name) => name.startsWith('node_modules'))).toBe(false); + expect(names.some((name) => name.includes('\\'))).toBe(false); expect(keyValueStoreGetOrCreate).not.toHaveBeenCalled(); + expect(logMessages.error.join('\n')).not.toContain('uncommitted changes'); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); - it('keeps stdout clean with --json', async () => { + it('keeps the --json envelope on stdout', async () => { await testRunCommand(ActorsPushCommand, { flags_json: true }); - expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); - expect(logMessages.log).toEqual([]); - expect(logMessages.error.join('\n')).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionPayload(versionUpdate).actorPath).toBe(ACTOR_PATH); + expect(logMessages.log).toHaveLength(1); + expect(JSON.parse(logMessages.log[0])).toMatchObject({ ok: true, operation: 'push', actor: { id: ACTOR_ID } }); + }); + + it('zips a repository over the multi-file limit into the OS temp directory and pushes it as a tarball', async () => { + await write(`${ACTOR_PATH}/big.bin`, 'x'.repeat(MAX_MULTIFILE_BYTES + 1)); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(keyValueStoreGetOrCreate).toHaveBeenCalledWith(`actor-${ACTOR_ID}-source`); + expect(setRecord).toHaveBeenCalledTimes(1); + const record = setRecord.mock.calls[0][0] as { key: string; value: Buffer; contentType: string }; + expect(record.key).toBe('version-0.0.zip'); + expect(record.contentType).toBe('application/zip'); + expect(Buffer.isBuffer(record.value)).toBe(true); + expect(record.value.length).toBeGreaterThan(0); + + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.TARBALL); + expect(modifier.sourceFiles).toBeUndefined(); + expect(modifier.actorPath).toBe(ACTOR_PATH); + const tarballUrl = new URL(modifier.tarballUrl!); + expect(tarballUrl.pathname).toBe('/v2/key-value-stores/store1/records/version-0.0.zip'); + expect(tarballUrl.searchParams.get('disableRedirect')).toBe('true'); + expect(tarballUrl.searchParams.get('signature')).toBeTruthy(); + + expect(existsSync(join(process.cwd(), 'temp_file.zip'))).toBe(false); + expect(existsSync(joinPath('temp_file.zip'))).toBe(false); + }); + + it('uploads the files under a directory symlink as copies and leaves a dangling link out', async () => { + symlinkSync('../../shared', joinPath(`${ACTOR_PATH}/shared`)); + symlinkSync('missing', joinPath('dangling')); + git('add', '.'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const names = versionPayload(versionUpdate).sourceFiles!.map((file) => file.name); + expect(names).toContain(`${ACTOR_PATH}/shared/TypeScript_Dockerfile`); + expect(names).toContain('shared/TypeScript_Dockerfile'); + expect(names).not.toContain(`${ACTOR_PATH}/shared`); + expect(names).not.toContain('dangling'); + }); + + it('warns about uncommitted changes, which the upload carries as they are', async () => { + await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(2);\n'); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const warningLine = logMessages.error.find((line) => line.includes('uncommitted changes')); + expect(warningLine).toBeDefined(); + expect(warningLine!.split('\n')).toHaveLength(1); + }); + + describe('on a platform without the actorPath field', () => { + it('turns the 400 into the refusal with the tracking issue', async () => { + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(actorDelete).not.toHaveBeenCalled(); + }); + + it('keeps stdout clean with --json', async () => { + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expectRefusal(); + expect(logMessages.log).toEqual([]); + }); + + it('deletes the Actor it has just created', async () => { + actorGet.mockResolvedValueOnce(null); + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(versionPayload(actorsCreate)).toMatchObject({ versions: [{ actorPath: ACTOR_PATH }] }); + expect(actorDelete).toHaveBeenCalledTimes(1); + }); + + it('still reports the refusal when deleting the just created Actor fails', async () => { + actorGet.mockResolvedValueOnce(null); + versionUpdate.mockRejectedValueOnce(actorPathRejected()); + actorDelete.mockRejectedValueOnce(new Error('delete failed')); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(actorDelete).toHaveBeenCalledTimes(1); + expect(logMessages.error.join('\n')).toContain('delete failed'); + }); + + it('refuses when the Actor create itself rejects actorPath, leaving nothing to delete', async () => { + actorGet.mockResolvedValueOnce(null); + actorsCreate.mockRejectedValueOnce(actorPathRejected()); + + await testRunCommand(ActorsPushCommand, {}); + + expectRefusal(); + expect(versionUpdate).not.toHaveBeenCalled(); + expect(actorDelete).not.toHaveBeenCalled(); + }); + + it('passes other API errors through', async () => { + versionUpdate.mockRejectedValueOnce(Object.assign(new Error('Version is locked'), { statusCode: 400 })); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(process.exitCode).toBe(1); + expect(logMessages.error.join('\n')).toContain('Version is locked'); + expect(logMessages.error.join('\n')).not.toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + }); + }); + + it('uploads the Docker context with .actorignore applied outside Git', async () => { + await rm(joinPath('.git'), { recursive: true, force: true }); + await write('.actorignore', 'packages/\n'); + gitState.gitAvailable = false; + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + const modifier = versionPayload(versionUpdate); + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); + expect(modifier.actorPath).toBe(ACTOR_PATH); + const names = modifier.sourceFiles!.map((file) => file.name); + expect(names).toEqual( + expect.arrayContaining([`${ACTOR_PATH}/.actor/actor.json`, 'package.json', 'shared/TypeScript_Dockerfile']), + ); + expect(names.some((name) => name.startsWith('packages/'))).toBe(false); + expect(names.some((name) => name.startsWith('node_modules'))).toBe(false); + }); + + it('rejects a dockerfile outside the Docker context before touching the platform', async () => { + await writeActorConfig({ dockerContextDir: '../..', dockerfile: '../../../shared/TypeScript_Dockerfile' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain('lies outside the Docker context'); + expect(actorGet).not.toHaveBeenCalled(); expect(versionUpdate).not.toHaveBeenCalled(); + expect(keyValueStoreGetOrCreate).not.toHaveBeenCalled(); }); it('rejects a dockerContextDir outside the Git repository, which the platform could not build', async () => { @@ -158,10 +377,10 @@ describe('apify push of a monorepo Actor', () => { await testRunCommand(ActorsPushCommand, {}); expect(commandProcess.exitCode).toBeFalsy(); - expect(versionUpdate).toHaveBeenCalledTimes(1); - const modifier = versionUpdate.mock.calls[0][0] as { sourceType: string; sourceFiles: { name: string }[] }; + const modifier = versionPayload(versionUpdate); 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']); + expect(modifier).not.toHaveProperty('actorPath'); expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); });