1
+# Copyright 2026 Google LLC
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+# http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+"""Tests for `colab run <script.py> [args...]` — shebang-friendly one-shot
16
+execution that bundles `colab new` + `colab exec` + `colab stop`.
17
+"""
18
+
19
+from unittest.mock import MagicMock
20
+
21
+import pytest
22
+from typer.testing import CliRunner
23
+
24
+from colab_cli.cli import app
25
+from colab_cli.client import (
26
+ Accelerator,
27
+ PostAssignmentResponse,
28
+ Variant,
29
+)
30
+
31
+runner = CliRunner()
32
+
33
+
34
+@pytest.fixture
35
+def mock_client(mock_common_state):
36
+ return mock_common_state.client
37
+
38
+
39
+@pytest.fixture
40
+def mock_store(mock_common_state):
41
+ return mock_common_state.store
42
+
43
+
44
+@pytest.fixture
45
+def mock_runtime_class(mocker):
46
+ """Patch ColabRuntime in the run module specifically."""
47
+ return mocker.patch("colab_cli.commands.run.ColabRuntime")
48
+
49
+
50
+@pytest.fixture
51
+def mock_spawn_keep_alive(mocker):
52
+ """Don't actually spawn a daemon during tests."""
53
+ return mocker.patch("colab_cli.commands.run.spawn_keep_alive", return_value=12345)
54
+
55
+
56
+@pytest.fixture
57
+def assign_response():
58
+ """A minimal PostAssignmentResponse-shaped mock for client.assign."""
59
+ res = MagicMock()
60
+ res.__class__ = PostAssignmentResponse
61
+ res.runtime_proxy_info.token = "tok"
62
+ res.runtime_proxy_info.url = "http://runtime"
63
+ res.endpoint = "ep-123"
64
+ return res
65
+
66
+
67
+@pytest.fixture
68
+def script_path(tmp_path):
69
+ p = tmp_path / "script.py"
70
+ p.write_text("print('hello from script')\n")
71
+ return p
72
+
73
+
74
+# ---------------------------------------------------------------------------
75
+# Happy path
76
+# ---------------------------------------------------------------------------
77
+
78
+
79
+def test_run_basic_flow(
80
+ mock_client,
81
+ mock_store,
82
+ mock_runtime_class,
83
+ mock_spawn_keep_alive,
84
+ assign_response,
85
+ script_path,
86
+):
87
+ """`colab run script.py` should: assign, exec, unassign."""
88
+ mock_client.assign.return_value = assign_response
89
+ mock_runtime = mock_runtime_class.return_value
90
+ mock_runtime.execute_code.return_value = []
91
+
92
+ # Simulate the persisted SessionState being readable by the run command.
93
+ persisted = {}
94
+
95
+ def store_add(s):
96
+ persisted["s"] = s
97
+
98
+ def store_get(name):
99
+ return persisted.get("s")
100
+
101
+ mock_store.add.side_effect = store_add
102
+ mock_store.get.side_effect = store_get
103
+
104
+ result = runner.invoke(app, ["run", str(script_path)])
105
+
106
+ assert result.exit_code == 0, result.output
107
+ # Allocation happened
108
+ mock_client.assign.assert_called_once()
109
+ # Script body was executed (the prelude + body is one execute_code call)
110
+ code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
111
+ assert any("hello from script" in code for code in code_calls), (
112
+ f"Script body never sent to runtime. Calls: {code_calls}"
113
+ )
114
+ # Cleanup happened
115
+ mock_client.unassign.assert_called_once_with("ep-123")
116
+
117
+
118
+# ---------------------------------------------------------------------------
119
+# --keep flag
120
+# ---------------------------------------------------------------------------
121
+
122
+
123
+def test_run_keep_skips_unassign(
124
+ mock_client,
125
+ mock_store,
126
+ mock_runtime_class,
127
+ mock_spawn_keep_alive,
128
+ assign_response,
129
+ script_path,
130
+):
131
+ """With `--keep`, the session must NOT be unassigned after the script
132
+ finishes — the user wants to attach to it later."""
133
+ mock_client.assign.return_value = assign_response
134
+ mock_runtime = mock_runtime_class.return_value
135
+ mock_runtime.execute_code.return_value = []
136
+
137
+ persisted = {}
138
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
139
+ mock_store.get.side_effect = lambda name: persisted.get("s")
140
+
141
+ result = runner.invoke(app, ["run", "--keep", str(script_path)])
142
+
143
+ assert result.exit_code == 0, result.output
144
+ mock_client.assign.assert_called_once()
145
+ mock_client.unassign.assert_not_called()
146
+ mock_store.remove.assert_not_called()
147
+
148
+
149
+# ---------------------------------------------------------------------------
150
+# argv passthrough
151
+# ---------------------------------------------------------------------------
152
+
153
+
154
+def test_run_passes_argv(
155
+ mock_client,
156
+ mock_store,
157
+ mock_runtime_class,
158
+ mock_spawn_keep_alive,
159
+ assign_response,
160
+ script_path,
161
+):
162
+ """Args after the script must be exposed as `sys.argv` inside the kernel."""
163
+ mock_client.assign.return_value = assign_response
164
+ mock_runtime = mock_runtime_class.return_value
165
+ mock_runtime.execute_code.return_value = []
166
+
167
+ persisted = {}
168
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
169
+ mock_store.get.side_effect = lambda name: persisted.get("s")
170
+
171
+ result = runner.invoke(
172
+ app, ["run", str(script_path), "alpha", "beta", "--flag-for-script"]
173
+ )
174
+
175
+ assert result.exit_code == 0, result.output
176
+ code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
177
+ # The execute_code call that contains the script body must also set
178
+ # sys.argv to mirror native python invocation.
179
+ body_calls = [c for c in code_calls if "hello from script" in c]
180
+ assert body_calls, f"Body never executed. Calls: {code_calls}"
181
+ body = body_calls[0]
182
+ assert "sys.argv" in body
183
+ assert "'script.py'" in body
184
+ assert "'alpha'" in body
185
+ assert "'beta'" in body
186
+ assert "'--flag-for-script'" in body
187
+
188
+
189
+def test_run_sets_dunder_main(
190
+ mock_client,
191
+ mock_store,
192
+ mock_runtime_class,
193
+ mock_spawn_keep_alive,
194
+ assign_response,
195
+ script_path,
196
+):
197
+ """The script must run with __name__ == '__main__'."""
198
+ mock_client.assign.return_value = assign_response
199
+ mock_runtime = mock_runtime_class.return_value
200
+ mock_runtime.execute_code.return_value = []
201
+
202
+ persisted = {}
203
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
204
+ mock_store.get.side_effect = lambda name: persisted.get("s")
205
+
206
+ result = runner.invoke(app, ["run", str(script_path)])
207
+ assert result.exit_code == 0, result.output
208
+ code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
209
+ body = next(c for c in code_calls if "hello from script" in c)
210
+ assert "__name__" in body and "'__main__'" in body
211
+
212
+
213
+# ---------------------------------------------------------------------------
214
+# Error handling
215
+# ---------------------------------------------------------------------------
216
+
217
+
218
+def test_run_propagates_error_exit_code(
219
+ mock_client,
220
+ mock_store,
221
+ mock_runtime_class,
222
+ mock_spawn_keep_alive,
223
+ assign_response,
224
+ script_path,
225
+):
226
+ """If the kernel reports an error, the CLI must exit non-zero AND still
227
+ unassign the VM (try/finally guarantee — AGENTS.md item 10)."""
228
+ mock_client.assign.return_value = assign_response
229
+ mock_runtime = mock_runtime_class.return_value
230
+
231
+ def execute_with_error(code, output_hook=None, **kwargs):
232
+ outputs = [
233
+ {
234
+ "output_type": "error",
235
+ "ename": "ValueError",
236
+ "evalue": "boom",
237
+ "traceback": ["Traceback...\n", "ValueError: boom\n"],
238
+ }
239
+ ]
240
+ if output_hook:
241
+ for o in outputs:
242
+ output_hook(o)
243
+ return outputs
244
+
245
+ mock_runtime.execute_code.side_effect = execute_with_error
246
+
247
+ persisted = {}
248
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
249
+ mock_store.get.side_effect = lambda name: persisted.get("s")
250
+
251
+ result = runner.invoke(app, ["run", str(script_path)])
252
+ assert result.exit_code != 0
253
+ # Cleanup MUST happen even on script failure.
254
+ mock_client.unassign.assert_called_once_with("ep-123")
255
+
256
+
257
+def test_run_unassign_called_on_exception_during_execute(
258
+ mock_client,
259
+ mock_store,
260
+ mock_runtime_class,
261
+ mock_spawn_keep_alive,
262
+ assign_response,
263
+ script_path,
264
+):
265
+ """Even if `runtime.execute_code` raises (e.g. websocket dies), the VM
266
+ must be released."""
267
+ mock_client.assign.return_value = assign_response
268
+ mock_runtime = mock_runtime_class.return_value
269
+ mock_runtime.execute_code.side_effect = RuntimeError("websocket closed")
270
+
271
+ persisted = {}
272
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
273
+ mock_store.get.side_effect = lambda name: persisted.get("s")
274
+
275
+ result = runner.invoke(app, ["run", str(script_path)])
276
+ assert result.exit_code != 0
277
+ mock_client.unassign.assert_called_once_with("ep-123")
278
+
279
+
280
+# ---------------------------------------------------------------------------
281
+# Accelerator passthrough
282
+# ---------------------------------------------------------------------------
283
+
284
+
285
+def test_run_with_gpu_flag(
286
+ mock_client,
287
+ mock_store,
288
+ mock_runtime_class,
289
+ mock_spawn_keep_alive,
290
+ assign_response,
291
+ script_path,
292
+):
293
+ """`colab run --gpu T4 script.py` must request a T4 GPU."""
294
+ mock_client.assign.return_value = assign_response
295
+ mock_runtime = mock_runtime_class.return_value
296
+ mock_runtime.execute_code.return_value = []
297
+
298
+ persisted = {}
299
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
300
+ mock_store.get.side_effect = lambda name: persisted.get("s")
301
+
302
+ result = runner.invoke(app, ["run", "--gpu", "T4", str(script_path)])
303
+ assert result.exit_code == 0, result.output
304
+
305
+ _, kwargs = mock_client.assign.call_args
306
+ assert kwargs["variant"] is Variant.GPU
307
+ assert kwargs["accelerator"] is Accelerator.T4
308
+
309
+
310
+def test_run_with_tpu_flag(
311
+ mock_client,
312
+ mock_store,
313
+ mock_runtime_class,
314
+ mock_spawn_keep_alive,
315
+ assign_response,
316
+ script_path,
317
+):
318
+ """`colab run --tpu v5e1 script.py` must request a TPU."""
319
+ mock_client.assign.return_value = assign_response
320
+ mock_runtime = mock_runtime_class.return_value
321
+ mock_runtime.execute_code.return_value = []
322
+
323
+ persisted = {}
324
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
325
+ mock_store.get.side_effect = lambda name: persisted.get("s")
326
+
327
+ result = runner.invoke(app, ["run", "--tpu", "v5e1", str(script_path)])
328
+ assert result.exit_code == 0, result.output
329
+
330
+ _, kwargs = mock_client.assign.call_args
331
+ assert kwargs["variant"] is Variant.TPU
332
+ assert kwargs["accelerator"] is Accelerator.V5E1
333
+
334
+
335
+# ---------------------------------------------------------------------------
336
+# Argument validation — fail FAST, before allocating a VM
337
+# ---------------------------------------------------------------------------
338
+
339
+
340
+def test_run_missing_script_errors(mock_client):
341
+ """Typer should reject the invocation if no script path is given."""
342
+ result = runner.invoke(app, ["run"])
343
+ assert result.exit_code != 0
344
+ mock_client.assign.assert_not_called()
345
+
346
+
347
+def test_run_nonexistent_script_errors_before_assign(mock_client):
348
+ """If the script doesn't exist locally, fail BEFORE allocating a VM —
349
+ otherwise a typo would burn billable compute."""
350
+ result = runner.invoke(app, ["run", "/no/such/file.py"])
351
+ assert result.exit_code != 0
352
+ mock_client.assign.assert_not_called()
353
+
354
+
355
+# ---------------------------------------------------------------------------
356
+# SystemExit handling — the kernel reports `sys.exit(N)` as an error output of
357
+# `ename=='SystemExit'`. We want native-`python`-like semantics: exit 0 for
358
+# `SystemExit(0)` (no traceback printed), and propagate the integer for
359
+# `SystemExit(N)`.
360
+# ---------------------------------------------------------------------------
361
+
362
+
363
+def _systemexit_output(evalue: str):
364
+ """Shape of the kernel's error output for `raise SystemExit(<evalue>)`."""
365
+ return {
366
+ "output_type": "error",
367
+ "ename": "SystemExit",
368
+ "evalue": evalue,
369
+ "traceback": [
370
+ "An exception has occurred, use %tb to see the full traceback.\n",
371
+ f"\x1b[0;31mSystemExit\x1b[0m\x1b[0;31m:\x1b[0m {evalue}\n",
372
+ ],
373
+ }
374
+
375
+
376
+def test_run_systemexit_zero_treated_as_success(
377
+ mock_client,
378
+ mock_store,
379
+ mock_runtime_class,
380
+ mock_spawn_keep_alive,
381
+ assign_response,
382
+ script_path,
383
+ capfd,
384
+):
385
+ """`raise SystemExit(0)` from the script body must NOT make the CLI exit
386
+ non-zero, AND the SystemExit traceback must NOT be printed (it's noise
387
+ that doesn't appear when running `python script.py`)."""
388
+ mock_client.assign.return_value = assign_response
389
+ mock_runtime = mock_runtime_class.return_value
390
+
391
+ def execute_with_systemexit(code, output_hook=None, **kwargs):
392
+ outputs = [_systemexit_output("0")]
393
+ if output_hook:
394
+ for o in outputs:
395
+ output_hook(o)
396
+ return outputs
397
+
398
+ mock_runtime.execute_code.side_effect = execute_with_systemexit
399
+
400
+ persisted = {}
401
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
402
+ mock_store.get.side_effect = lambda name: persisted.get("s")
403
+
404
+ result = runner.invoke(app, ["run", str(script_path)])
405
+ captured = capfd.readouterr()
406
+
407
+ assert result.exit_code == 0, result.output
408
+ # The IPython "An exception has occurred..." traceback must be suppressed.
409
+ assert "An exception has occurred" not in (
410
+ result.output + result.stderr + captured.out + captured.err
411
+ )
412
+ # Cleanup still happened.
413
+ mock_client.unassign.assert_called_once_with("ep-123")
414
+
415
+
416
+def test_run_systemexit_nonzero_propagates_code(
417
+ mock_client,
418
+ mock_store,
419
+ mock_runtime_class,
420
+ mock_spawn_keep_alive,
421
+ assign_response,
422
+ script_path,
423
+):
424
+ """`raise SystemExit(7)` from the script must surface as exit code 7
425
+ (matching `python script.py` semantics)."""
426
+ mock_client.assign.return_value = assign_response
427
+ mock_runtime = mock_runtime_class.return_value
428
+
429
+ def execute_with_systemexit(code, output_hook=None, **kwargs):
430
+ outputs = [_systemexit_output("7")]
431
+ if output_hook:
432
+ for o in outputs:
433
+ output_hook(o)
434
+ return outputs
435
+
436
+ mock_runtime.execute_code.side_effect = execute_with_systemexit
437
+
438
+ persisted = {}
439
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
440
+ mock_store.get.side_effect = lambda name: persisted.get("s")
441
+
442
+ result = runner.invoke(app, ["run", str(script_path)])
443
+ assert result.exit_code == 7
444
+ mock_client.unassign.assert_called_once_with("ep-123")
445
+
446
+
447
+def test_run_systemexit_string_message_exits_one(
448
+ mock_client,
449
+ mock_store,
450
+ mock_runtime_class,
451
+ mock_spawn_keep_alive,
452
+ assign_response,
453
+ script_path,
454
+):
455
+ """`sys.exit('boom')` (string arg, like `python -c "import sys; sys.exit(\"x\")"`)
456
+ must (a) exit non-zero (CPython uses 1) and (b) print the message so the
457
+ user sees what went wrong."""
458
+ mock_client.assign.return_value = assign_response
459
+ mock_runtime = mock_runtime_class.return_value
460
+
461
+ def execute_with_systemexit(code, output_hook=None, **kwargs):
462
+ outputs = [_systemexit_output("boom")]
463
+ if output_hook:
464
+ for o in outputs:
465
+ output_hook(o)
466
+ return outputs
467
+
468
+ mock_runtime.execute_code.side_effect = execute_with_systemexit
469
+
470
+ persisted = {}
471
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
472
+ mock_store.get.side_effect = lambda name: persisted.get("s")
473
+
474
+ result = runner.invoke(app, ["run", str(script_path)])
475
+ assert result.exit_code == 1
476
+ mock_client.unassign.assert_called_once_with("ep-123")
477
+
478
+
479
+def test_run_prelude_suppresses_ipython_exit_warning(
480
+ mock_client,
481
+ mock_store,
482
+ mock_runtime_class,
483
+ mock_spawn_keep_alive,
484
+ assign_response,
485
+ script_path,
486
+):
487
+ """The prelude must mute IPython's 'To exit: use exit, quit, or Ctrl-D'
488
+ UserWarning, which fires whenever the user calls `sys.exit(...)` (i.e.
489
+ every well-formed CLI script)."""
490
+ mock_client.assign.return_value = assign_response
491
+ mock_runtime = mock_runtime_class.return_value
492
+ mock_runtime.execute_code.return_value = []
493
+
494
+ persisted = {}
495
+ mock_store.add.side_effect = lambda s: persisted.setdefault("s", s)
496
+ mock_store.get.side_effect = lambda name: persisted.get("s")
497
+
498
+ result = runner.invoke(app, ["run", str(script_path)])
499
+ assert result.exit_code == 0, result.output
500
+
501
+ # Find the body-bearing execute_code call.
502
+ code_calls = [c.args[0] for c in mock_runtime.execute_code.call_args_list]
503
+ body = next(c for c in code_calls if "hello from script" in c)
504
+ # Look for the warnings filter targeting IPython's exit-warning text.
505
+ assert "warnings.filterwarnings" in body
506
+ assert "To exit: use" in body