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