|
1 | 1 | import json |
| 2 | +import logging |
| 3 | +import os |
2 | 4 | from pathlib import Path |
3 | 5 | from typing import Any |
4 | 6 |
|
5 | 7 | import pytest |
6 | 8 | from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError |
| 9 | +from uipath.core.triggers import UiPathResumeTrigger |
7 | 10 |
|
8 | 11 | from uipath.runtime.context import UiPathRuntimeContext |
9 | 12 | from uipath.runtime.errors import ( |
@@ -427,3 +430,240 @@ def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None: |
427 | 430 | ctx = UiPathRuntimeContext.from_config(str(config_path)) |
428 | 431 |
|
429 | 432 | assert ctx.voice_mode == "maestro_flow" |
| 433 | + |
| 434 | + |
| 435 | +def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None: |
| 436 | + """runtime.splitOutputArguments should map onto the knob.""" |
| 437 | + cfg = {"runtime": {"splitOutputArguments": True}} |
| 438 | + config_path = tmp_path / "uipath.json" |
| 439 | + config_path.write_text(json.dumps(cfg)) |
| 440 | + |
| 441 | + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) |
| 442 | + |
| 443 | + assert ctx.split_output_arguments is True |
| 444 | + |
| 445 | + |
| 446 | +def test_split_output_arguments_defaults_off_when_config_key_absent( |
| 447 | + tmp_path: Path, |
| 448 | +) -> None: |
| 449 | + """The split stays off when the config omits the key.""" |
| 450 | + cfg = {"runtime": {"outputFile": "my_output.json"}} |
| 451 | + config_path = tmp_path / "uipath.json" |
| 452 | + config_path.write_text(json.dumps(cfg)) |
| 453 | + |
| 454 | + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) |
| 455 | + |
| 456 | + assert ctx.split_output_arguments is False |
| 457 | + |
| 458 | + |
| 459 | +def test_output_arguments_file_is_a_sibling_of_the_result_file( |
| 460 | + tmp_path: Path, monkeypatch: pytest.MonkeyPatch |
| 461 | +) -> None: |
| 462 | + """The arguments file lands next to the result file, never in the process CWD. |
| 463 | +
|
| 464 | + The host names the directory once, through runtime_dir, and both files follow |
| 465 | + it. The filename is not configurable, so the knob has exactly one encoding and |
| 466 | + the two files cannot be pointed at different directories. |
| 467 | + """ |
| 468 | + cwd = tmp_path / "cwd" |
| 469 | + cwd.mkdir() |
| 470 | + monkeypatch.chdir(cwd) |
| 471 | + runtime_dir = tmp_path / "runtime" |
| 472 | + ctx = UiPathRuntimeContext( |
| 473 | + job_id="job-sibling", |
| 474 | + runtime_dir=str(runtime_dir), |
| 475 | + result_file="result.json", |
| 476 | + split_output_arguments=True, |
| 477 | + ) |
| 478 | + |
| 479 | + arguments_path = Path(ctx.resolved_output_arguments_file_path) |
| 480 | + assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent |
| 481 | + assert arguments_path.parent == runtime_dir |
| 482 | + assert arguments_path.name == "output.args.json" |
| 483 | + assert arguments_path.is_absolute() |
| 484 | + assert cwd not in arguments_path.parents |
| 485 | + |
| 486 | + |
| 487 | +def test_result_file_keeps_output_inline_when_split_disabled( |
| 488 | + tmp_path: Path, |
| 489 | +) -> None: |
| 490 | + """Without the knob, the result file is byte-identical to the legacy envelope.""" |
| 491 | + runtime_dir = tmp_path / "runtime" |
| 492 | + ctx = UiPathRuntimeContext( |
| 493 | + job_id="job-inline", |
| 494 | + runtime_dir=str(runtime_dir), |
| 495 | + result_file="result.json", |
| 496 | + ) |
| 497 | + |
| 498 | + with ctx: |
| 499 | + ctx.result = UiPathRuntimeResult( |
| 500 | + status=UiPathRuntimeStatus.SUCCESSFUL, |
| 501 | + output={"foo": "bar"}, |
| 502 | + ) |
| 503 | + |
| 504 | + result_path = Path(ctx.resolved_result_file_path) |
| 505 | + # The envelope is written in text mode, so json's newline reaches disk as os.linesep |
| 506 | + expected = json.dumps( |
| 507 | + {"output": {"foo": "bar"}, "status": "successful"}, indent=2 |
| 508 | + ).replace("\n", os.linesep) |
| 509 | + assert result_path.read_bytes() == expected.encode() |
| 510 | + |
| 511 | + content = json.loads(result_path.read_bytes()) |
| 512 | + assert "outputArgumentsFilePath" not in content |
| 513 | + assert not Path(ctx.resolved_output_arguments_file_path).exists() |
| 514 | + |
| 515 | + |
| 516 | +def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None: |
| 517 | + """With the knob, the arguments move out and the envelope carries the path.""" |
| 518 | + runtime_dir = tmp_path / "nested" / "runtime" |
| 519 | + ctx = UiPathRuntimeContext( |
| 520 | + job_id="job-split", |
| 521 | + runtime_dir=str(runtime_dir), |
| 522 | + result_file="result.json", |
| 523 | + split_output_arguments=True, |
| 524 | + ) |
| 525 | + |
| 526 | + with ctx: |
| 527 | + ctx.result = UiPathRuntimeResult( |
| 528 | + status=UiPathRuntimeStatus.SUCCESSFUL, |
| 529 | + output={"foo": "bar"}, |
| 530 | + ) |
| 531 | + |
| 532 | + arguments_path = Path(ctx.resolved_output_arguments_file_path) |
| 533 | + # Parent directory is created on demand |
| 534 | + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} |
| 535 | + |
| 536 | + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) |
| 537 | + assert "output" not in content |
| 538 | + assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value |
| 539 | + assert content["outputArgumentsFilePath"] == str(arguments_path) |
| 540 | + assert Path(content["outputArgumentsFilePath"]).is_absolute() |
| 541 | + |
| 542 | + |
| 543 | +def test_output_file_receives_bare_arguments_when_split_enabled( |
| 544 | + tmp_path: Path, |
| 545 | +) -> None: |
| 546 | + """--output-file keeps receiving the bare arguments when both are set.""" |
| 547 | + runtime_dir = tmp_path / "runtime" |
| 548 | + output_path = tmp_path / "output.json" |
| 549 | + ctx = UiPathRuntimeContext( |
| 550 | + job_id="job-both", |
| 551 | + runtime_dir=str(runtime_dir), |
| 552 | + result_file="result.json", |
| 553 | + output_file=str(output_path), |
| 554 | + split_output_arguments=True, |
| 555 | + ) |
| 556 | + |
| 557 | + with ctx: |
| 558 | + ctx.result = UiPathRuntimeResult( |
| 559 | + status=UiPathRuntimeStatus.SUCCESSFUL, |
| 560 | + output={"foo": "bar"}, |
| 561 | + ) |
| 562 | + |
| 563 | + assert json.loads(output_path.read_text()) == {"foo": "bar"} |
| 564 | + arguments_path = Path(ctx.resolved_output_arguments_file_path) |
| 565 | + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} |
| 566 | + |
| 567 | + |
| 568 | +def test_faulted_run_keeps_status_and_error_inline_when_split_enabled( |
| 569 | + tmp_path: Path, |
| 570 | +) -> None: |
| 571 | + """status and error stay in the envelope when the arguments are split out.""" |
| 572 | + runtime_dir = tmp_path / "runtime" |
| 573 | + ctx = UiPathRuntimeContext( |
| 574 | + job_id="job-faulted-split", |
| 575 | + runtime_dir=str(runtime_dir), |
| 576 | + result_file="result.json", |
| 577 | + split_output_arguments=True, |
| 578 | + ) |
| 579 | + |
| 580 | + with pytest.raises(RuntimeError, match="Stream blew up"): |
| 581 | + with ctx: |
| 582 | + raise RuntimeError("Stream blew up") |
| 583 | + |
| 584 | + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) |
| 585 | + assert content["status"] == UiPathRuntimeStatus.FAULTED.value |
| 586 | + assert content["error"]["code"] == "ERROR_RuntimeError" |
| 587 | + assert "Stream blew up" in content["error"]["detail"] |
| 588 | + assert "output" not in content |
| 589 | + |
| 590 | + # The pointer must never advertise a file that was not actually written |
| 591 | + arguments_path = Path(content["outputArgumentsFilePath"]) |
| 592 | + assert arguments_path.exists() |
| 593 | + assert json.loads(arguments_path.read_text()) == {} |
| 594 | + |
| 595 | + |
| 596 | +def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None: |
| 597 | + """resume and resumeTriggers must never be moved out of the envelope. |
| 598 | +
|
| 599 | + They are what makes a suspended job resumable, so a split that swept them |
| 600 | + into the arguments file would strand the job. |
| 601 | + """ |
| 602 | + runtime_dir = tmp_path / "runtime" |
| 603 | + ctx = UiPathRuntimeContext( |
| 604 | + job_id="job-suspended-split", |
| 605 | + runtime_dir=str(runtime_dir), |
| 606 | + result_file="result.json", |
| 607 | + split_output_arguments=True, |
| 608 | + ) |
| 609 | + |
| 610 | + trigger = UiPathResumeTrigger(item_key="k") |
| 611 | + with ctx: |
| 612 | + ctx.result = UiPathRuntimeResult( |
| 613 | + status=UiPathRuntimeStatus.SUSPENDED, |
| 614 | + output={"foo": "bar"}, |
| 615 | + trigger=trigger, |
| 616 | + triggers=[trigger], |
| 617 | + ) |
| 618 | + |
| 619 | + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) |
| 620 | + assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value |
| 621 | + assert content["resume"]["itemKey"] == "k" |
| 622 | + assert len(content["resumeTriggers"]) == 1 |
| 623 | + assert content["resumeTriggers"][0]["itemKey"] == "k" |
| 624 | + # Only the output moved out |
| 625 | + assert "output" not in content |
| 626 | + arguments_path = Path(content["outputArgumentsFilePath"]) |
| 627 | + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} |
| 628 | + |
| 629 | + |
| 630 | +def test_failed_arguments_write_degrades_to_inline_output( |
| 631 | + tmp_path: Path, caplog: pytest.LogCaptureFixture |
| 632 | +) -> None: |
| 633 | + """A failing arguments write keeps the output inline and the run successful. |
| 634 | +
|
| 635 | + Contract: splitting the arguments out is only an optimization, so a failed |
| 636 | + write must never turn an otherwise successful run into a faulted one. |
| 637 | + """ |
| 638 | + runtime_dir = tmp_path / "runtime" |
| 639 | + runtime_dir.mkdir() |
| 640 | + # A directory cannot be opened for writing, so the split write fails |
| 641 | + (runtime_dir / "output.args.json").mkdir() |
| 642 | + ctx = UiPathRuntimeContext( |
| 643 | + job_id="job-degraded-write", |
| 644 | + runtime_dir=str(runtime_dir), |
| 645 | + result_file="result.json", |
| 646 | + split_output_arguments=True, |
| 647 | + ) |
| 648 | + |
| 649 | + with caplog.at_level(logging.WARNING, logger="uipath.runtime.context"): |
| 650 | + with ctx: |
| 651 | + ctx.result = UiPathRuntimeResult( |
| 652 | + status=UiPathRuntimeStatus.SUCCESSFUL, |
| 653 | + output={"foo": "bar"}, |
| 654 | + ) |
| 655 | + |
| 656 | + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) |
| 657 | + assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value |
| 658 | + assert content["output"] == {"foo": "bar"} |
| 659 | + assert "outputArgumentsFilePath" not in content |
| 660 | + assert "error" not in content |
| 661 | + |
| 662 | + record = next( |
| 663 | + r |
| 664 | + for r in caplog.records |
| 665 | + if "Failed to write the output arguments file" in r.message |
| 666 | + ) |
| 667 | + # The cause is interpolated and the traceback preserved, not just the prefix |
| 668 | + assert "%s" not in record.message |
| 669 | + assert record.exc_info is not None |
0 commit comments