@@ -81,10 +81,10 @@ def test_hash_length(self):
8181class TestGenerateContainerfile :
8282 """Tests for generate_containerfile function."""
8383
84- def test_starts_with_from (self ):
85- """Test that Containerfile starts with FROM."""
84+ def test_starts_with_from_and_init_stage (self ):
85+ """Test that Containerfile starts with FROM and init stage ."""
8686 result = generate_containerfile ("alpine:latest" )
87- assert result .startswith ("FROM alpine:latest" )
87+ assert result .startswith ("FROM alpine:latest AS init " )
8888
8989 def test_contains_boost_marker (self ):
9090 """Test that Containerfile creates boost marker."""
@@ -136,7 +136,7 @@ def test_has_pre_init_hooks_with_arg(self):
136136 assert "echo hello" in result
137137
138138 def test_order_of_sections (self ):
139- """Test that sections appear in correct order."""
139+ """Test that sections appear in correct order with multi-stage build ."""
140140 result = generate_containerfile (
141141 "alpine:latest" ,
142142 additional_packages = "git" ,
@@ -146,44 +146,173 @@ def test_order_of_sections(self):
146146 lines = result .split ("\n " )
147147
148148 # Find positions
149+ init_stage_pos = None
149150 boost_pos = None
150151 upgrade_pos = None
151- pre_hook_pos = None
152152 install_pos = None
153- additional_pos = None
154- init_hook_pos = None
153+ pre_hooks_stage_pos = None
154+ packages_stage_pos = None
155+ runner_stage_pos = None
155156
156157 for i , line in enumerate (lines ):
158+ if "FROM alpine:latest AS init" in line :
159+ init_stage_pos = i
157160 if "/.distrobox-boost" in line :
158161 boost_pos = i
159162 if "# Upgrade" in line :
160163 upgrade_pos = i
161- if "# Pre-init hooks" in line :
162- pre_hook_pos = i
163164 if "# Install distrobox dependencies" in line :
164165 install_pos = i
165- if "# Install additional packages" in line :
166- additional_pos = i
167- if "# Init hooks" in line :
168- init_hook_pos = i
169-
170- # Verify order
166+ if "FROM init AS pre-hooks" in line :
167+ pre_hooks_stage_pos = i
168+ if "FROM pre-hooks AS packages" in line :
169+ packages_stage_pos = i
170+ if "FROM packages AS runner" in line :
171+ runner_stage_pos = i
172+
173+ # Verify all stages present
174+ assert init_stage_pos is not None
171175 assert boost_pos is not None
172176 assert upgrade_pos is not None
173- assert pre_hook_pos is not None
174177 assert install_pos is not None
175- assert additional_pos is not None
176- assert init_hook_pos is not None
178+ assert pre_hooks_stage_pos is not None
179+ assert packages_stage_pos is not None
180+ assert runner_stage_pos is not None
177181
182+ # Verify order within init stage
183+ assert init_stage_pos < boost_pos
178184 assert boost_pos < upgrade_pos
179- assert upgrade_pos < pre_hook_pos
180- assert pre_hook_pos < install_pos
181- assert install_pos < additional_pos
182- assert additional_pos < init_hook_pos
185+ assert upgrade_pos < install_pos
186+
187+ # Verify stage order
188+ assert install_pos < pre_hooks_stage_pos
189+ assert pre_hooks_stage_pos < packages_stage_pos
190+ assert packages_stage_pos < runner_stage_pos
183191
184192 def test_returns_multiline_string (self ):
185193 """Test that result is a valid multiline Containerfile."""
186194 result = generate_containerfile ("alpine:latest" )
187195 lines = result .split ("\n " )
188196 assert len (lines ) > 5 # Should have multiple lines
189197 assert lines [0 ].startswith ("FROM" )
198+
199+
200+ class TestMultiStageBuild :
201+ """Tests for multi-stage build structure."""
202+
203+ def test_multi_stage_all_options (self ):
204+ """Test that all 4 stages are present when all options provided."""
205+ result = generate_containerfile (
206+ "alpine:latest" ,
207+ additional_packages = "git vim" ,
208+ init_hooks = "echo init" ,
209+ pre_init_hooks = "echo pre" ,
210+ )
211+
212+ assert "FROM alpine:latest AS init" in result
213+ assert "FROM init AS pre-hooks" in result
214+ assert "FROM pre-hooks AS packages" in result
215+ assert "FROM packages AS runner" in result
216+
217+ def test_multi_stage_skip_pre_hooks (self ):
218+ """Test that pre-hooks stage is skipped when no pre_init_hooks."""
219+ result = generate_containerfile (
220+ "alpine:latest" ,
221+ additional_packages = "git" ,
222+ init_hooks = "echo init" ,
223+ )
224+
225+ assert "FROM alpine:latest AS init" in result
226+ assert "AS pre-hooks" not in result
227+ # packages should inherit from init directly
228+ assert "FROM init AS packages" in result
229+ assert "FROM packages AS runner" in result
230+
231+ def test_multi_stage_skip_packages (self ):
232+ """Test that packages stage is skipped when no additional_packages."""
233+ result = generate_containerfile (
234+ "alpine:latest" ,
235+ init_hooks = "echo init" ,
236+ pre_init_hooks = "echo pre" ,
237+ )
238+
239+ assert "FROM alpine:latest AS init" in result
240+ assert "FROM init AS pre-hooks" in result
241+ assert "AS packages" not in result
242+ # runner should inherit from pre-hooks directly
243+ assert "FROM pre-hooks AS runner" in result
244+
245+ def test_multi_stage_only_packages (self ):
246+ """Test with only additional_packages."""
247+ result = generate_containerfile (
248+ "alpine:latest" ,
249+ additional_packages = "git vim" ,
250+ )
251+
252+ assert "FROM alpine:latest AS init" in result
253+ assert "AS pre-hooks" not in result
254+ assert "FROM init AS packages" in result
255+ assert "AS runner" not in result
256+
257+ def test_multi_stage_base_only (self ):
258+ """Test that only init stage exists when no optional args."""
259+ result = generate_containerfile ("alpine:latest" )
260+
261+ assert "FROM alpine:latest AS init" in result
262+ assert "AS pre-hooks" not in result
263+ assert "AS packages" not in result
264+ assert "AS runner" not in result
265+ # Verify base content still present
266+ assert "touch /.distrobox-boost" in result
267+ assert "# Upgrade existing packages" in result
268+ assert "# Install distrobox dependencies" in result
269+
270+ def test_stage_chain_init_to_runner (self ):
271+ """Test correct FROM chain: init -> runner (no middle stages)."""
272+ result = generate_containerfile (
273+ "alpine:latest" ,
274+ init_hooks = "echo init" ,
275+ )
276+
277+ assert "FROM alpine:latest AS init" in result
278+ assert "FROM init AS runner" in result
279+ assert "AS pre-hooks" not in result
280+ assert "AS packages" not in result
281+
282+ def test_stage_chain_with_pre_hooks_only (self ):
283+ """Test correct FROM chain: init -> pre-hooks (no packages/runner)."""
284+ result = generate_containerfile (
285+ "alpine:latest" ,
286+ pre_init_hooks = "echo pre" ,
287+ )
288+
289+ assert "FROM alpine:latest AS init" in result
290+ assert "FROM init AS pre-hooks" in result
291+ assert "AS packages" not in result
292+ assert "AS runner" not in result
293+
294+ def test_stage_chain_packages_and_runner (self ):
295+ """Test correct FROM chain when skipping pre-hooks."""
296+ result = generate_containerfile (
297+ "alpine:latest" ,
298+ additional_packages = "git" ,
299+ init_hooks = "echo init" ,
300+ )
301+
302+ # packages should come from init (not pre-hooks)
303+ assert "FROM init AS packages" in result
304+ # runner should come from packages
305+ assert "FROM packages AS runner" in result
306+
307+ def test_stage_chain_pre_hooks_and_runner (self ):
308+ """Test correct FROM chain when skipping packages."""
309+ result = generate_containerfile (
310+ "alpine:latest" ,
311+ init_hooks = "echo init" ,
312+ pre_init_hooks = "echo pre" ,
313+ )
314+
315+ # pre-hooks from init
316+ assert "FROM init AS pre-hooks" in result
317+ # runner should come from pre-hooks (not packages)
318+ assert "FROM pre-hooks AS runner" in result
0 commit comments