From 4968e9fa2bf622df15da5292534f9709a5d64338 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:54:40 +0100 Subject: [PATCH] test: add unit tests for Dockerfile ARG insertion logic --- tests/Unit/DockerfileArgInsertionTest.php | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/Unit/DockerfileArgInsertionTest.php diff --git a/tests/Unit/DockerfileArgInsertionTest.php b/tests/Unit/DockerfileArgInsertionTest.php new file mode 100644 index 000000000..593f09145 --- /dev/null +++ b/tests/Unit/DockerfileArgInsertionTest.php @@ -0,0 +1,218 @@ +makePartial(); + + $dockerfile = collect([ + 'FROM node:16', + 'WORKDIR /app', + 'COPY . .', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0]); +}); + +it('finds FROM instructions with comments before', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Build stage', + '# Another comment', + 'FROM node:16', + 'WORKDIR /app', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([2]); +}); + +it('finds multiple FROM instructions in multi-stage dockerfile', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + 'FROM node:16 AS builder', + 'WORKDIR /app', + 'RUN npm install', + '', + 'FROM nginx:alpine', + 'COPY --from=builder /app/dist /usr/share/nginx/html', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0, 4]); +}); + +it('handles FROM with different cases', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + 'from node:16', + 'From nginx:alpine', + 'FROM alpine:latest', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([0, 1, 2]); +}); + +it('returns empty array when no FROM instructions found', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Just comments', + 'WORKDIR /app', + 'RUN npm install', + ]); + + $result = $job->findFromInstructionLines($dockerfile); + + expect($result)->toBe([]); +}); + +it('inserts ARGs after FROM in simple dockerfile', function () { + $dockerfile = collect([ + 'FROM node:16', + 'WORKDIR /app', + 'COPY . .', + ]); + + $fromLines = [0]; + $argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + expect($dockerfile[0])->toBe('FROM node:16'); + expect($dockerfile[1])->toBe('ARG MY_VAR=value'); + expect($dockerfile[2])->toBe('ARG ANOTHER_VAR'); + expect($dockerfile[3])->toBe('WORKDIR /app'); +}); + +it('inserts ARGs after each FROM in multi-stage dockerfile', function () { + $dockerfile = collect([ + 'FROM node:16 AS builder', + 'WORKDIR /app', + '', + 'FROM nginx:alpine', + 'COPY --from=builder /app/dist /usr/share/nginx/html', + ]); + + $fromLines = [0, 3]; + $argsToInsert = collect(['ARG MY_VAR=value']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + // First stage + expect($dockerfile[0])->toBe('FROM node:16 AS builder'); + expect($dockerfile[1])->toBe('ARG MY_VAR=value'); + expect($dockerfile[2])->toBe('WORKDIR /app'); + + // Second stage (index shifted by +1 due to inserted ARG) + expect($dockerfile[4])->toBe('FROM nginx:alpine'); + expect($dockerfile[5])->toBe('ARG MY_VAR=value'); +}); + +it('inserts ARGs after FROM when comments precede FROM', function () { + $dockerfile = collect([ + '# Build stage comment', + 'FROM node:16', + 'WORKDIR /app', + ]); + + $fromLines = [1]; + $argsToInsert = collect(['ARG MY_VAR=value']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + expect($dockerfile[0])->toBe('# Build stage comment'); + expect($dockerfile[1])->toBe('FROM node:16'); + expect($dockerfile[2])->toBe('ARG MY_VAR=value'); + expect($dockerfile[3])->toBe('WORKDIR /app'); +}); + +it('handles real-world nuxt multi-stage dockerfile with comments', function () { + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + + $dockerfile = collect([ + '# Build Stage 1', + '', + 'FROM node:22-alpine AS build', + 'WORKDIR /app', + '', + 'RUN corepack enable', + '', + '# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration', + 'COPY package.json pnpm-lock.yaml .npmrc ./', + '', + '# Install dependencies', + 'RUN pnpm i', + '', + '# Copy the entire project', + 'COPY . ./', + '', + '# Build the project', + 'RUN pnpm run build', + '', + '# Build Stage 2', + '', + 'FROM node:22-alpine', + 'WORKDIR /app', + '', + '# Only `.output` folder is needed from the build stage', + 'COPY --from=build /app/.output/ ./', + '', + '# Change the port and host', + 'ENV PORT=80', + 'ENV HOST=0.0.0.0', + '', + 'EXPOSE 80', + '', + 'CMD ["node", "/app/server/index.mjs"]', + ]); + + // Find FROM instructions + $fromLines = $job->findFromInstructionLines($dockerfile); + + expect($fromLines)->toBe([2, 21]); + + // Simulate ARG insertion + $argsToInsert = collect(['ARG BUILD_VAR=production']); + + foreach (array_reverse($fromLines) as $fromLineIndex) { + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + + // Verify first stage + expect($dockerfile[2])->toBe('FROM node:22-alpine AS build'); + expect($dockerfile[3])->toBe('ARG BUILD_VAR=production'); + expect($dockerfile[4])->toBe('WORKDIR /app'); + + // Verify second stage (index shifted by +1 due to first ARG insertion) + expect($dockerfile[22])->toBe('FROM node:22-alpine'); + expect($dockerfile[23])->toBe('ARG BUILD_VAR=production'); + expect($dockerfile[24])->toBe('WORKDIR /app'); +});