diff --git a/.github/codecov.yaml b/.github/codecov.yaml new file mode 100644 index 0000000..108479e --- /dev/null +++ b/.github/codecov.yaml @@ -0,0 +1,18 @@ +# ref: https://docs.codecov.com/docs/codecovyml-reference +coverage: + range: 95..98 + round: down + precision: 1 + status: + # ref: https://docs.codecov.com/docs/commit-status + project: + default: + # Avoid false negatives + threshold: 1% + patch: + default: + informational: true + +comment: + layout: "condensed_header, condensed_files" + require_changes: true \ No newline at end of file diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..4a7c0f3 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,76 @@ +name: check + +on: + push: + branches: [dev, main] + pull_request: + +# only read-only for GITHUB_TOKEN +permissions: + contents: read + +# cancel old jobs since their results will be discarded anyway +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + fmt: + runs-on: ubuntu-latest + name: stable / fmt + steps: + - uses: actions/checkout@v4 + - name: Install stable rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --check + run: cargo fmt --check + + clippy: + runs-on: ubuntu-latest + name: ${{ matrix.toolchain }} / clippy + permissions: + contents: read + checks: write + strategy: + fail-fast: false + matrix: + # Get early warning of new lints which are regularly introduced in beta channels. + toolchain: [stable, beta] + steps: + - uses: actions/checkout@v4 + - name: Install ${{ matrix.toolchain }} rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + components: clippy + - name: cargo clippy + uses: giraffate/clippy-action@v1 + with: + reporter: 'github-pr-check' + github_token: ${{ secrets.GITHUB_TOKEN }} + + hack: + runs-on: ubuntu-latest + name: ubuntu / stable / features + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: cargo install cargo-hack + uses: taiki-e/install-action@cargo-hack + - name: cargo hack + run: cargo hack --feature-powerset --no-dev-deps check + + doc: + runs-on: ubuntu-latest + name: nightly / doc + steps: + - uses: actions/checkout@v4 + - name: Install nightly + uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-docs-rs + uses: dtolnay/install@cargo-docs-rs + - name: cargo docs-rs + run: cargo docs-rs diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..ee08dd8 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,54 @@ +name: publish + +on: + push: + branches: [main] + +# only read-only for GITHUB_TOKEN +permissions: + contents: read + +jobs: + publish_audit: + name: audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + publish_test: + name: test on ${{ matrix.os }} / stable + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: cargo test --locked + run: cargo test --locked --all-features + + publish: + name: publish to crates.io + needs: + - publish_audit + - publish_test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: release + - name: cargo login + run: cargo login ${{ secrets.CRATES_IO_TOKEN }} + - name: Publish + run: |- + cargo release \ + publish \ + --all-features \ + --allow-branch HEAD \ + --no-confirm \ + --execute diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..9cf0f88 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,96 @@ +name: test + +on: + push: + branches: [dev, main] + pull_request: + +# only read-only for GITHUB_TOKEN +permissions: + contents: read + +# cancel old jobs since their results will be discarded anyway +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + required: + runs-on: ubuntu-latest + name: ubuntu / ${{ matrix.toolchain }} + strategy: + matrix: + # run on stable and beta to ensure that tests won't break on the next version of the rust + # toolchain + toolchain: [stable, beta] + steps: + - uses: actions/checkout@v4 + - name: Install ${{ matrix.toolchain }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + - name: cargo test --locked + run: cargo test --locked --all-features + + os-check: + runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} / stable + strategy: + fail-fast: false + matrix: + # ubuntu-latest is covered in `required` + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + - name: cargo test + run: cargo test --locked --all-features --all-targets + + coverage: + runs-on: ubuntu-latest + name: ubuntu / stable / coverage + steps: + - uses: actions/checkout@v4 + - name: Install stable + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - name: cargo install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: cargo llvm-cov + run: cargo llvm-cov --locked --all-features --lcov --output-path lcov.info + - name: Record Rust version + run: echo "RUSTVER=$(rustc --version)" >> "$GITHUB_ENV" + - name: Upload to codecov.io + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + env_vars: OS,RUSTVER + + spec: + runs-on: ubuntu-latest + name: examples / spec validation + steps: + - uses: actions/checkout@v4 + - name: Install rust stable + uses: dtolnay/rust-toolchain@stable + - name: pull validator + run: git clone https://github.com/opencomputeproject/ocp-diag-core.git --depth=1 + - name: Install go + uses: actions/setup-go@v2 + with: + go-version: "1.17.6" + - name: run validator against examples + run: | + ROOT="$(pwd)" + cd ocp-diag-core/validators/spec_validator + cargo metadata --manifest-path $ROOT/Cargo.toml --format-version 1 | + jq -r '.["packages"][] | select(.name == "ocptv") | .targets[] | select(.kind[0] == "example") | .name' | + xargs -I{} bash -c " + echo validating output of example {}... && + cargo run --manifest-path $ROOT/Cargo.toml --example {} | + tee /dev/stderr | + go run . -schema ../../json_spec/output/root.json - + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccb5166 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.vscode \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d7749e7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1399 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anyhow" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "assert_fs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efdb1fdb47602827a342857666feb372712cbc64b414172bd6b167a02927674" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "bstr" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + +[[package]] +name = "cc" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e804ac3194a48bb129643eb1d62fcc20d18c6b8c181704489353d13120bcd1" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "delegate" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2323e10c92e1cf4d86e11538512e6dc03ceb586842970b6332af3d4046a046" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.0", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocptv" +version = "0.1.1" +dependencies = [ + "anyhow", + "assert-json-diff", + "assert_fs", + "async-trait", + "chrono", + "chrono-tz", + "delegate", + "futures", + "maplit", + "mime", + "predicates", + "rand", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-test", + "unwrap-infallible", + "url", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" + +[[package]] +name = "predicates-tree" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.6.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + +[[package]] +name = "thiserror" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unwrap-infallible" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151ac09978d3c2862c4e39b557f4eceee2cc72150bc4cb4f16abf061b6e381fb" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..18f459c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "ocptv" +version = "0.1.1" +description = "Strongly typed Rust API for OCPTV output" +authors = ["OCP Test & Validation Project"] +keywords = ["ocptv", "hardware", "validation"] +repository = "https://github.com/opencomputeproject/ocp-diag-core-rust" +license = "MIT" +edition = "2021" + +[dependencies] +async-trait = "0.1.83" +chrono = "0.4.38" +chrono-tz = "0.10.0" +delegate = "0.13.1" +maplit = "1.0.2" +mime = "0.3.17" +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +serde_with = "3.11.0" +thiserror = "1.0.64" +tokio = { version = "1.40.0", features = [ + "rt", + "rt-multi-thread", + "macros", + "io-util", + "fs", + "sync", +] } +unwrap-infallible = "0.1.5" +url = "2.5.2" + +[dev-dependencies] +anyhow = "1.0.89" +assert-json-diff = "2.0.2" +assert_fs = "1.1.2" +futures = "0.3.30" +predicates = "3.1.2" +tokio-test = "0.4.4" +rand = "0.8.5" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', +] } diff --git a/DEVELOPERS.md b/DEVELOPERS.md new file mode 100644 index 0000000..68a829b --- /dev/null +++ b/DEVELOPERS.md @@ -0,0 +1,42 @@ +### Coding style + +- unit tests should return a `Result` type. Example: + ```rust + #[test] + fn is_equal_to_42() -> anyhow::Result<()> { + let x = maybe_return_42()?; + assert_eq!(x, 42); + Ok(()) + } + ``` + +### Release process + +To make a new release, and publish to crates.io, a new tagged commit needs to exist on the `main` branch. This is done with a simple merge from the `dev` branch. **Do not** push any other kinds of commits to the `main` branch. + +Steps: +1. bump the version. Will need [`cargo-release`](https://crates.io/crates/cargo-release) crate. Example here bumps the *patch* version. +```bash +$ git checkout dev +$ cargo release version patch --execute +$ cargo release changes # note any changelog to add to the commit, or manually craft it +$ git add . +$ git commit +$ git push origin dev +``` +2. merge `dev` into `main` +```bash +$ git checkout main +$ git merge --no-ff dev +``` +3. tag the merge commit +```bash +$ git checkout main +$ cargo release tag --sign-tag --execute +``` +4. push with tags +```bash +$ git checkout main +$ git push +$ git push --tags +``` diff --git a/README.md b/README.md index 0a81535..ba29a3c 100644 --- a/README.md +++ b/README.md @@ -1 +1,177 @@ -# ocp-diag-core-rust \ No newline at end of file +# ocp-diag-core-rust + +[![codecov](https://codecov.io/github/opencomputeproject/ocp-diag-core-rust/graph/badge.svg?token=IJOG0T8XZ3)](https://codecov.io/github/opencomputeproject/ocp-diag-core-rust) + +The **OCP Test & Validation Initiative** is a collaboration between datacenter hyperscalers having the goal of standardizing aspects of the hardware validation/diagnosis space, along with providing necessary tooling to enable both diagnostic developers and executors to leverage these interfaces. + +Specifically, the [ocp-diag-core-rust](https://github.com/opencomputeproject/ocp-diag-core-rust) project makes it easy for developers to use the **OCP Test & Validation specification** artifacts by presenting a pure-rust api that can be used to output spec compliant JSON messages. + +To start, please see below for [installation instructions](https://github.com/opencomputeproject/ocp-diag-core-rust#installation) and [usage](https://github.com/opencomputeproject/ocp-diag-core-rust#usage). + +This project is part of [ocp-diag-core](https://github.com/opencomputeproject/ocp-diag-core) and exists under the same [MIT License Agreement](https://github.com/opencomputeproject/ocp-diag-core-rust/LICENSE). + +### Installation + +Stable releases of the **ocp-diag-core-rust** codebase are published to **crates.io** under the package name [ocptv](https://crates.io/crates/ocptv), and can be easily installed with cargo. + +For general usage, the following steps should be sufficient to get the latest stable version using the [Package Installer for Rust](https://github.com/rust-lang/cargo): + +- **\[option 1]** using `cargo add` + + ```bash + $ cargo add ocptv + ``` + +- **\[option 2]** specifying it in Cargo.toml file + + + ```toml + [dependencies] + ocptv = "0.1.0" + ``` + +To use the bleeding edge instead of the stable version, the dependecies section should be modified like this: + +``` +[dependencies] +ocptv = { git = "https://github.com/opencomputeproject/ocp-diag-core-rust.git", branch = "dev" } +``` + +The instructions above assume a Linux-type system. However, the steps should be identical on Windows and MacOS platforms. + +See [The Cargo Book](https://doc.rust-lang.org/cargo/index.html) for more details on how to use cargo. + +### Usage + +The [specification](https://github.com/opencomputeproject/ocp-diag-core/tree/main/json_spec) does not impose any particular level of usage. To be compliant, a diagnostic package just needs output the correct artifact messages in the correct format. However, any particular such diagnostic is free to choose what aspects it needs to use/output; eg. a simple validation test may not output any measurements, opting to just have a final Diagnosis outcome. + +**Full API reference is available [here](https://docs.rs/ocptv).** + +A very simple starter example, which just outputs a diagnosis: +```rust +use anyhow::Result; + +use ocptv::output as tv; +use ocptv::{ocptv_diagnosis_fail, ocptv_diagnosis_pass}; +use rand::Rng; +use tv::{TestResult, TestStatus}; + +fn get_fan_speed() -> i32 { + let mut rng = rand::thread_rng(); + rng.gen_range(1500..1700) +} + +async fn run_diagnosis_step(step: tv::ScopedTestStep) -> Result { + let fan_speed = get_fan_speed(); + + if fan_speed >= 1600 { + step.add_diagnosis("fan_ok", tv::DiagnosisType::Pass).await?; + } else { + step.add_diagnosis("fan_low", tv::DiagnosisType::Fail).await?; + } + + Ok(TestStatus::Complete) +} + +async fn run_diagnosis_macros_step(step: tv::ScopedTestStep) -> Result { + let fan_speed = get_fan_speed(); + + /// using the macro, the source location is filled automatically + if fan_speed >= 1600 { + ocptv_diagnosis_pass!(step, "fan_ok").await?; + } else { + ocptv_diagnosis_fail!(step, "fan_low").await?; + } + + Ok(TestStatus::Complete) +} + +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0") + .scope(run_diagnosis_step) + .await?; + + r.add_step("step1") + .scope(run_diagnosis_macros_step) + .await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} +``` + +Expected output (slightly reformatted for readability): + +```json +{"sequenceNumber":0, "schemaVersion":{"major":2,"minor":0},"timestamp":"2024-10-11T11:39:07.026Z"} + +{"sequenceNumber":1,"testRunArtifact":{ + "testRunStart":{ + "name":"simple diagnosis", + "commandLine":"","parameters":{},"version":"1.0", + "dutInfo":{"dutInfoId":"dut0"} + }},"timestamp":"2024-10-11T11:39:07.026Z"} + +{"sequenceNumber":2,"testStepArtifact":{ + "testStepId":"step0","testStepStart":{"name":"step0"} + },"timestamp":"2024-10-11T11:39:07.026Z"} + +{"sequenceNumber":3,"testStepArtifact":{ + "diagnosis":{"type":"PASS","verdict":"fan_ok"}, + "testStepId":"step0"},"timestamp":"2024-10-11T11:39:07.027Z"} + +{"sequenceNumber":4,"testStepArtifact":{ + "testStepEnd":{"status":"COMPLETE"},"testStepId":"step0" + },"timestamp":"2024-10-11T11:39:07.027Z"} + +{"sequenceNumber":5,"testStepArtifact":{ + "testStepId":"step1","testStepStart":{"name":"step1"} + },"timestamp":"2024-10-11T11:39:07.027Z"} + +{"sequenceNumber":6,"testStepArtifact":{ + "diagnosis":{ + "sourceLocation":{"file":"examples/diagnosis.rs","line":40}, + "type":"FAIL","verdict":"fan_low" + },"testStepId":"step1" + },"timestamp":"2024-10-11T11:39:07.027Z"} + +{"sequenceNumber":7,"testStepArtifact":{ + "testStepEnd":{"status":"COMPLETE"},"testStepId":"step1" + },"timestamp":"2024-10-11T11:39:07.027Z"} + +{"sequenceNumber":8,"testRunArtifact":{ + "testRunEnd":{"result":"PASS","status":"COMPLETE"} + },"timestamp":"2024-10-11T11:39:07.027Z"} + +``` + +### Examples + +The examples in [examples folder](https://github.com/opencomputeproject/ocp-diag-core-rust/tree/dev/examples) can be run using cargo. + +```bash +# run diagnosis example +$ cargo run --example diagnosis +``` + +### Developer notes + +If you would like to contribute, please head over to [developer notes](https://github.com/opencomputeproject/ocp-diag-core-rust/tree/dev/DEVELOPERS.md) for instructions. + +### Contact + +Feel free to start a new [discussion](https://github.com/opencomputeproject/ocp-diag-core-rust/discussions), or otherwise post an [issue/request](https://github.com/opencomputeproject/ocp-diag-core-rust/issues). + +An email contact is also available at: ocp-test-validation@OCP-All.groups.io diff --git a/examples/custom_writer.rs b/examples/custom_writer.rs new file mode 100644 index 0000000..ad73ea3 --- /dev/null +++ b/examples/custom_writer.rs @@ -0,0 +1,59 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::io; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::sync::mpsc; + +use ocptv::ocptv_log_debug; +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +struct Channel { + tx: mpsc::Sender, +} + +#[async_trait] +impl tv::Writer for Channel { + async fn write(&self, s: &str) -> Result<(), io::Error> { + self.tx.send(s.to_owned()).await.map_err(io::Error::other)?; + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let (tx, mut rx) = mpsc::channel::(1); + let task = tokio::spawn(async move { + while let Some(s) = rx.recv().await { + println!("{}", s); + } + }); + + let config = tv::Config::builder() + .with_custom_output(Box::new(Channel { tx })) + .build(); + + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("extensions", "1.0") + .config(config) + .build() + .scope(dut, |r| async move { + ocptv_log_debug!(r, "log debug").await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + task.await?; + Ok(()) +} diff --git a/examples/diagnosis.rs b/examples/diagnosis.rs new file mode 100644 index 0000000..59c23a6 --- /dev/null +++ b/examples/diagnosis.rs @@ -0,0 +1,64 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use ocptv::{ocptv_diagnosis_fail, ocptv_diagnosis_pass}; +use rand::Rng; +use tv::{TestResult, TestStatus}; + +fn get_fan_speed() -> i32 { + let mut rng = rand::thread_rng(); + rng.gen_range(1500..1700) +} + +async fn run_diagnosis_step(step: tv::ScopedTestStep) -> Result { + let fan_speed = get_fan_speed(); + + if fan_speed >= 1600 { + step.add_diagnosis("fan_ok", tv::DiagnosisType::Pass) + .await?; + } else { + step.add_diagnosis("fan_low", tv::DiagnosisType::Fail) + .await?; + } + + Ok(TestStatus::Complete) +} + +async fn run_diagnosis_macros_step(step: tv::ScopedTestStep) -> Result { + let fan_speed = get_fan_speed(); + + if fan_speed >= 1600 { + ocptv_diagnosis_pass!(step, "fan_ok").await?; + } else { + ocptv_diagnosis_fail!(step, "fan_low").await?; + } + + Ok(TestStatus::Complete) +} + +/// Simple demo with diagnosis. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(run_diagnosis_step).await?; + r.add_step("step1").scope(run_diagnosis_macros_step).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/error_while_gathering_duts.rs b/examples/error_while_gathering_duts.rs new file mode 100644 index 0000000..d5cf42e --- /dev/null +++ b/examples/error_while_gathering_duts.rs @@ -0,0 +1,21 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::ocptv_error; +use ocptv::output as tv; + +/// In case of failure to discover DUT hardware before needing to present it at test run +/// start, we can error out right at the beginning since no Diagnosis can be produced. +/// This is a framework failure. +#[tokio::main] +async fn main() -> Result<()> { + let run = tv::TestRun::builder("error while gathering duts", "1.0").build(); + ocptv_error!(run, "no-dut", "could not find any valid DUTs").await?; + + Ok(()) +} diff --git a/examples/error_with_dut.rs b/examples/error_with_dut.rs new file mode 100644 index 0000000..9194802 --- /dev/null +++ b/examples/error_with_dut.rs @@ -0,0 +1,41 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{SoftwareType, TestResult, TestStatus}; + +/// Show outputting an error message, triggered by a specific software component of the DUT. +#[tokio::main] +async fn main() -> Result<()> { + let mut dut = tv::DutInfo::builder("dut0").name("dut0.server.net").build(); + let sw_info = dut.add_software_info( + tv::SoftwareInfo::builder("bmc") + .software_type(SoftwareType::Firmware) + .version("2.5") + .build(), + ); + + tv::TestRun::builder("run error with dut", "1.0") + .build() + .scope(dut, |r| async move { + r.add_error_detail( + tv::Error::builder("power-fail") + .add_software_info(&sw_info) + .build(), + ) + .await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Fail, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/extensions.rs b/examples/extensions.rs new file mode 100644 index 0000000..4a377fe --- /dev/null +++ b/examples/extensions.rs @@ -0,0 +1,59 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde::Serialize; + +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +#[derive(Serialize)] +enum ExtensionType { + Example, +} + +#[derive(Serialize)] +struct Extension { + #[serde(rename = "@type")] + ext_type: ExtensionType, + + field: String, + subtypes: Vec, +} + +async fn step0(s: tv::ScopedTestStep) -> Result { + s.add_extension( + "ext0", + Extension { + ext_type: ExtensionType::Example, + field: "example".to_owned(), + subtypes: vec![1, 42], + }, + ) + .await?; + + Ok(TestStatus::Complete) +} + +/// Showcase how to output a vendor specific test step extension. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("extensions", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(step0).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/file.rs b/examples/file.rs new file mode 100644 index 0000000..9b29ca1 --- /dev/null +++ b/examples/file.rs @@ -0,0 +1,39 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::str::FromStr; + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +async fn run_file_step(step: tv::ScopedTestStep) -> Result { + let uri = tv::Uri::from_str("file:///root/mem_cfg_log").unwrap(); + step.add_file("mem_cfg_log", uri).await?; + + Ok(TestStatus::Complete) +} + +/// Simple demo with file. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(run_file_step).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/measurement_concurrency.rs b/examples/measurement_concurrency.rs new file mode 100644 index 0000000..d9bdcaa --- /dev/null +++ b/examples/measurement_concurrency.rs @@ -0,0 +1,62 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::{sync::Arc, time::Duration}; + +use anyhow::Result; +use rand; + +use ocptv::output as tv; +use tv::{DutInfo, TestResult, TestRun, TestRunOutcome, TestStatus}; + +/// While the general recommendation is to run test steps sequentially, the specification does not +/// mandate for this to happen. This example shows multiple steps running in parallel, each +/// emitting their own measurements. +#[tokio::main(flavor = "multi_thread", worker_threads = 10)] +async fn main() -> Result<()> { + let dut = DutInfo::builder("dut0").build(); + + TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + let run = Arc::new(r); + + let tasks = (0..5) + .map(|i| { + tokio::spawn({ + let r = Arc::clone(&run); + async move { + r.add_step(&format!("step{}", i)) + .scope(move |s| async move { + let offset = rand::random::() % 10000; + tokio::time::sleep(Duration::from_micros(offset)).await; + + let fan_speed = 1000 + 100 * i; + s.add_measurement(&format!("fan{}", i), fan_speed) + .await + .unwrap(); + + Ok(TestStatus::Complete) + }) + .await + } + }) + }) + .collect::>(); + + for t in tasks { + t.await.map_err(|e| tv::OcptvError::Other(Box::new(e)))??; + } + + Ok(TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/measurement_series.rs b/examples/measurement_series.rs new file mode 100644 index 0000000..cea0e74 --- /dev/null +++ b/examples/measurement_series.rs @@ -0,0 +1,109 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use chrono::Duration; + +use ocptv::output::{self as tv}; +use tv::{TestResult, TestStatus}; + +async fn step0_measurements(step: tv::ScopedTestStep) -> Result { + let fan_speed = step + .add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("fan_speed") + .unit("rpm") + .build(), + ) + .start() + .await?; + + fan_speed.add_measurement(1000).await?; + fan_speed.add_measurement(1200).await?; + fan_speed.add_measurement(1500).await?; + + fan_speed.end().await?; + Ok(TestStatus::Complete) +} + +async fn step1_measurements(step: tv::ScopedTestStep) -> Result { + step.add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("temp0") + .unit("C") + .build(), + ) + .scope(|s| async move { + let two_seconds_ago = + chrono::Local::now().with_timezone(&chrono_tz::UTC) - Duration::seconds(2); + s.add_measurement_detail( + tv::MeasurementElementDetail::builder(42) + .timestamp(two_seconds_ago) + .build(), + ) + .await?; + + s.add_measurement(43).await?; + Ok(()) + }) + .await?; + + Ok(TestStatus::Complete) +} + +async fn step2_measurements(step: tv::ScopedTestStep) -> Result { + let freq0 = step + .add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("freq0") + .unit("hz") + .build(), + ) + .start() + .await?; + + let freq1 = step + .add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("freq0") + .unit("hz") + .build(), + ) + .start() + .await?; + + freq0.add_measurement(1.0).await?; + freq1.add_measurement(2.0).await?; + freq0.add_measurement(1.2).await?; + + freq0.end().await?; + freq1.end().await?; + Ok(TestStatus::Complete) +} + +/// Show various patterns of time measurement series. +/// +/// Step0 has a single series, manually ended. +/// Step1 has a single series but using a scope, so series ends automatically. +/// Step2 shows multiple measurement interspersed series, they can be concurrent. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(step0_measurements).await?; + + r.add_step("step1").scope(step1_measurements).await?; + + r.add_step("step2").scope(step2_measurements).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/measurement_single.rs b/examples/measurement_single.rs new file mode 100644 index 0000000..41c2f7e --- /dev/null +++ b/examples/measurement_single.rs @@ -0,0 +1,42 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +async fn run_measure_step(step: tv::ScopedTestStep) -> Result { + step.add_measurement("temperature", 42.5).await?; + step.add_measurement_detail( + tv::Measurement::builder("fan_speed", 1200) + .unit("rpm") + .build(), + ) + .await?; + + Ok(TestStatus::Complete) +} + +/// Simple demo with some measurements taken but not referencing DUT hardware. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(run_measure_step).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/measurement_subcomponent.rs b/examples/measurement_subcomponent.rs new file mode 100644 index 0000000..0eb01f2 --- /dev/null +++ b/examples/measurement_subcomponent.rs @@ -0,0 +1,99 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{SubcomponentType, TestResult, TestStatus}; + +async fn run_measure_step( + step: tv::ScopedTestStep, + ram0: tv::DutHardwareInfo, +) -> Result { + step.add_measurement_detail( + tv::Measurement::builder("temp0", 100.5) + .unit("F") + .hardware_info(&ram0) + .subcomponent(tv::Subcomponent::builder("chip0").build()) + .build(), + ) + .await?; + + let chip1_temp = step.add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("temp1") + .unit("C") + .hardware_info(&ram0) + .subcomponent( + tv::Subcomponent::builder("chip1") + .location("U11") + .version("1") + .revision("1") + .subcomponent_type(SubcomponentType::Unspecified) + .build(), + ) + .build(), + ); + + chip1_temp + .scope(|s| async move { + s.add_measurement(79).await?; + + Ok(()) + }) + .await?; + + Ok(TestStatus::Complete) +} + +/// This is a more comprehensive example with a DUT having both hardware and software +/// components specified. The measurements reference the hardware items. +#[tokio::main] +async fn main() -> Result<()> { + let mut dut = tv::DutInfo::builder("dut0") + .name("host0.example.com") + .add_platform_info(tv::PlatformInfo::new("memory-optimized")) + .build(); + + dut.add_software_info( + tv::SoftwareInfo::builder("bmc0") + .software_type(tv::SoftwareType::Firmware) + .version("10") + .revision("11") + .computer_system("primary_node") + .build(), + ); + + let ram0 = dut.add_hardware_info( + tv::HardwareInfo::builder("ram0") + .version("1") + .revision("2") + .location("MB/DIMM_A1") + .serial_no("HMA2022029281901") + .part_no("P03052-091") + .manufacturer("hynix") + .manufacturer_part_no("HMA84GR7AFR4N-VK") + .odata_id("/redfish/v1/Systems/System.Embedded.1/Memory/DIMMSLOTA1") + .computer_system("primary_node") + .manager("bmc0") + .build(), + ); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0") + .scope(|s| run_measure_step(s, ram0)) + .await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/measurement_validators.rs b/examples/measurement_validators.rs new file mode 100644 index 0000000..09b2c3f --- /dev/null +++ b/examples/measurement_validators.rs @@ -0,0 +1,66 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{TestResult, TestStatus, ValidatorType}; + +async fn run_measure_step(step: tv::ScopedTestStep) -> Result { + step.add_measurement_detail( + tv::Measurement::builder("temp", 40) + .add_validator( + tv::Validator::builder(ValidatorType::GreaterThan, 30) + .name("gt_30") + .build(), + ) + .build(), + ) + .await?; + + step.add_measurement_series_detail( + tv::MeasurementSeriesDetail::builder("fan_speed") + .unit("rpm") + .add_validator(tv::Validator::builder(ValidatorType::LessThanOrEqual, 3000).build()) + .build(), + ) + .scope(|s| async move { + s.add_measurement(1000).await?; + + Ok(()) + }) + .await?; + + step.add_measurement_detail( + tv::Measurement::builder("fan_speed", 1200) + .unit("rpm") + .build(), + ) + .await?; + + Ok(TestStatus::Complete) +} + +/// Showcase a measurement item and series, both using validators to document +/// what the diagnostic package actually validated. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("simple measurement", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0").scope(run_measure_step).await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) +} diff --git a/examples/simple_no_scopes.rs b/examples/simple_no_scopes.rs new file mode 100644 index 0000000..187214b --- /dev/null +++ b/examples/simple_no_scopes.rs @@ -0,0 +1,31 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::ocptv_log_debug; +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +/// Show that a run/step can be manually started and ended. +/// +/// The scope version should be preferred, as it makes it safer not to miss the end +/// artifacts in case of unhandled exceptions or code misuse. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + let run = tv::TestRun::builder("no scopes", "1.0") + .build() + .start(dut) + .await?; + + let step = run.add_step("step0").start().await?; + ocptv_log_debug!(step, "Some interesting message.").await?; + step.end(TestStatus::Complete).await?; + + run.end(TestStatus::Complete, TestResult::Pass).await?; + Ok(()) +} diff --git a/examples/simple_run_skip.rs b/examples/simple_run_skip.rs new file mode 100644 index 0000000..5a1d650 --- /dev/null +++ b/examples/simple_run_skip.rs @@ -0,0 +1,32 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +/// Show a context-scoped run that automatically exits the whole func +/// because of the marker exception that triggers SKIP outcome. +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("run skip", "1.0") + .build() + .scope(dut, |_r| { + async move { + // intentional short return + return Ok(tv::TestRunOutcome { + status: TestStatus::Skip, + result: TestResult::NotApplicable, + }); + } + }) + .await?; + + Ok(()) +} diff --git a/examples/simple_step_fail.rs b/examples/simple_step_fail.rs new file mode 100644 index 0000000..eba2402 --- /dev/null +++ b/examples/simple_step_fail.rs @@ -0,0 +1,41 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; + +use ocptv::ocptv_log_info; +use ocptv::output as tv; +use tv::{TestResult, TestStatus}; + +/// Show a scoped run with scoped steps, everything starts at "with" time and +/// ends automatically when the block ends (regardless of unhandled exceptions). +#[tokio::main] +async fn main() -> Result<()> { + let dut = tv::DutInfo::builder("dut0").build(); + + tv::TestRun::builder("step fail", "1.0") + .build() + .scope(dut, |r| async move { + r.add_step("step0") + .scope(|s| async move { + ocptv_log_info!(s, "info log").await?; + Ok(TestStatus::Complete) + }) + .await?; + + r.add_step("step1") + .scope(|_s| async move { Ok(TestStatus::Error) }) + .await?; + + Ok(tv::TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Fail, + }) + }) + .await?; + + Ok(()) +} diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..c598031 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -eo pipefail + +# (c) Meta Platforms, Inc. and affiliates. +# +# Use of this source code is governed by an MIT-style +# license that can be found in the LICENSE file or at +# https://opensource.org/licenses/MIT. + +echo "Running CI checks..." + +cargo fmt --check + +# ensure the tests run ok with all features disabled +cargo test + +cargo test --locked --all-features + +# docs-rs supersedes cargo doc +cargo +nightly docs-rs + +# finish with coverage, so we get an output to check +cargo llvm-cov --locked --all-features diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ee95e44 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +pub mod output; +mod spec; diff --git a/src/output/config.rs b/src/output/config.rs new file mode 100644 index 0000000..984ba91 --- /dev/null +++ b/src/output/config.rs @@ -0,0 +1,107 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::path::Path; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::output as tv; +use crate::output::writer::{self, BufferWriter, FileWriter, StdoutWriter, WriterType}; + +/// The configuration repository for the TestRun. +pub struct Config { + // All fields are readable for any impl inside the crate. + pub(crate) timestamp_provider: Box, + pub(crate) writer: WriterType, +} + +impl Config { + /// Creates a new [`ConfigBuilder`] + /// + /// # Examples + /// ```rust + /// # use ocptv::output::*; + /// let builder = Config::builder(); + /// ``` + pub fn builder() -> ConfigBuilder { + ConfigBuilder::new() + } +} + +/// The builder for the [`Config`] object. +pub struct ConfigBuilder { + timestamp_provider: Box, + writer: Option, +} + +impl ConfigBuilder { + fn new() -> Self { + Self { + timestamp_provider: Box::new(ConfiguredTzProvider { tz: chrono_tz::UTC }), + writer: Some(WriterType::Stdout(StdoutWriter::new())), + } + } + + /// TODO: docs for all these + pub fn timezone(mut self, timezone: chrono_tz::Tz) -> Self { + self.timestamp_provider = Box::new(ConfiguredTzProvider { tz: timezone }); + self + } + + pub fn with_timestamp_provider( + mut self, + timestamp_provider: Box, + ) -> Self { + self.timestamp_provider = timestamp_provider; + self + } + + pub fn with_buffer_output(mut self, buffer: Arc>>) -> Self { + self.writer = Some(WriterType::Buffer(BufferWriter::new(buffer))); + self + } + + pub async fn with_file_output>( + mut self, + path: P, + ) -> Result { + self.writer = Some(WriterType::File(FileWriter::new(path).await?)); + Ok(self) + } + + pub fn with_custom_output( + mut self, + custom: Box, + ) -> Self { + self.writer = Some(WriterType::Custom(custom)); + self + } + + pub fn build(self) -> Config { + Config { + timestamp_provider: self.timestamp_provider, + writer: self + .writer + .unwrap_or(WriterType::Stdout(StdoutWriter::new())), + } + } +} + +/// TODO: docs +pub trait TimestampProvider { + fn now(&self) -> chrono::DateTime; +} + +struct ConfiguredTzProvider { + tz: chrono_tz::Tz, +} + +impl TimestampProvider for ConfiguredTzProvider { + fn now(&self) -> chrono::DateTime { + chrono::Local::now().with_timezone(&self.tz) + } +} diff --git a/src/output/diagnosis.rs b/src/output/diagnosis.rs new file mode 100644 index 0000000..9333427 --- /dev/null +++ b/src/output/diagnosis.rs @@ -0,0 +1,301 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use crate::output as tv; +use crate::spec; +use tv::dut; + +/// This structure represents a Diagnosis message. +/// +/// ref: +/// +/// Information about the source file and line number are not automatically added. +/// Add them using the builder or the macros octptv_diagnosis_* +/// +/// # Examples +/// +/// ## Create a Diagnosis object with the `new` method +/// +/// ``` +/// # use ocptv::output::*; +/// let diagnosis = Diagnosis::new("verdict", DiagnosisType::Pass); +/// ``` +/// +/// ## Create a Diagnosis object with the `builder` method +/// +/// ``` +/// # use ocptv::output::*; +/// let mut dut = DutInfo::new("dut0"); +/// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); +/// +/// let diagnosis = Diagnosis::builder("verdict", DiagnosisType::Pass) +/// .message("message") +/// .hardware_info(&hw_info) +/// .subcomponent(&Subcomponent::builder("name").build()) +/// .source("file.rs", 1) +/// .build(); +/// ``` +#[derive(Default)] +pub struct Diagnosis { + verdict: String, + diagnosis_type: spec::DiagnosisType, + message: Option, + hardware_info: Option, + subcomponent: Option, + source_location: Option, +} + +impl Diagnosis { + /// Builds a new Diagnosis object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// + /// let diagnosis = Diagnosis::new("verdict", DiagnosisType::Pass); + /// ``` + pub fn new(verdict: &str, diagnosis_type: spec::DiagnosisType) -> Self { + Diagnosis { + verdict: verdict.to_owned(), + diagnosis_type, + ..Default::default() + } + } + + /// Builds a new Diagnosis object using [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("dut0"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + /// + /// let diagnosis = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .message("message") + /// .hardware_info(&hw_info) + /// .subcomponent(&Subcomponent::builder("name").build()) + /// .source("file.rs", 1) + /// .build(); + /// ``` + pub fn builder(verdict: &str, diagnosis_type: spec::DiagnosisType) -> DiagnosisBuilder { + DiagnosisBuilder::new(verdict, diagnosis_type) + } + + /// Creates an artifact from a Diagnosis object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let diagnosis = Diagnosis::new("verdict", DiagnosisType::Pass); + /// let _ = diagnosis.to_artifact(); + /// ``` + pub fn to_artifact(&self) -> spec::Diagnosis { + spec::Diagnosis { + verdict: self.verdict.clone(), + diagnosis_type: self.diagnosis_type.clone(), + message: self.message.clone(), + hardware_info: self + .hardware_info + .as_ref() + .map(dut::DutHardwareInfo::to_spec), + subcomponent: self + .subcomponent + .as_ref() + .map(|subcomponent| subcomponent.to_spec()), + source_location: self.source_location.clone(), + } + } +} + +/// This structure builds a [`Diagnosis`] object. +/// +/// # Examples +/// +/// ``` +/// # use ocptv::output::*; +/// let mut dut = DutInfo::new("dut0"); +/// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); +/// +/// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass) +/// .message("message") +/// .hardware_info(&hw_info) +/// .subcomponent(&Subcomponent::builder("name").build()) +/// .source("file.rs", 1); +/// let diagnosis = builder.build(); +/// ``` +#[derive(Default)] +pub struct DiagnosisBuilder { + verdict: String, + diagnosis_type: spec::DiagnosisType, + message: Option, + + hardware_info: Option, + subcomponent: Option, + + source_location: Option, +} + +impl DiagnosisBuilder { + fn new(verdict: &str, diagnosis_type: spec::DiagnosisType) -> Self { + DiagnosisBuilder { + verdict: verdict.to_owned(), + diagnosis_type, + ..Default::default() + } + } + + /// Add a message to a [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .message("message"); + /// ``` + pub fn message(mut self, message: &str) -> Self { + self.message = Some(message.to_owned()); + self + } + + /// Add a [`dut::HardwareInfo`] to a [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("dut0"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + /// + /// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .hardware_info(&hw_info); + /// ``` + pub fn hardware_info(mut self, hardware_info: &dut::DutHardwareInfo) -> Self { + self.hardware_info = Some(hardware_info.clone()); + self + } + + /// Add a [`dut::Subcomponent`] to a [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .subcomponent(&Subcomponent::builder("name").build()); + /// ``` + pub fn subcomponent(mut self, subcomponent: &dut::Subcomponent) -> Self { + self.subcomponent = Some(subcomponent.clone()); + self + } + + /// Add a source location to a [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .source("file.rs", 1); + /// ``` + pub fn source(mut self, file: &str, line: i32) -> Self { + self.source_location = Some(spec::SourceLocation { + file: file.to_owned(), + line, + }); + self + } + + /// Builds a [`Diagnosis`] object from a [`DiagnosisBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Diagnosis::builder("verdict", DiagnosisType::Pass); + /// let diagnosis = builder.build(); + /// ``` + pub fn build(self) -> Diagnosis { + Diagnosis { + verdict: self.verdict, + diagnosis_type: self.diagnosis_type, + message: self.message, + hardware_info: self.hardware_info, + subcomponent: self.subcomponent, + source_location: self.source_location, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output as tv; + use crate::spec; + use anyhow::Result; + use tv::dut::*; + + #[test] + fn test_diagnosis_as_test_step_descendant_to_artifact() -> Result<()> { + let verdict = "verdict".to_owned(); + let diagnosis_type = spec::DiagnosisType::Pass; + let diagnosis = Diagnosis::new(&verdict, diagnosis_type.clone()); + + let artifact = diagnosis.to_artifact(); + + assert_eq!( + artifact, + spec::Diagnosis { + verdict: verdict.to_owned(), + diagnosis_type, + message: None, + hardware_info: None, + subcomponent: None, + source_location: None, + } + ); + + Ok(()) + } + + #[test] + fn test_diagnosis_builder_as_test_step_descendant_to_artifact() -> Result<()> { + let mut dut = DutInfo::new("dut0"); + + let verdict = "verdict".to_owned(); + let diagnosis_type = spec::DiagnosisType::Pass; + let hardware_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + let subcomponent = Subcomponent::builder("name").build(); + let file = "file.rs".to_owned(); + let line = 1; + let message = "message".to_owned(); + + let diagnosis = Diagnosis::builder(&verdict, diagnosis_type.clone()) + .hardware_info(&hardware_info) + .message(&message) + .subcomponent(&subcomponent) + .source(&file, line) + .build(); + + let artifact = diagnosis.to_artifact(); + assert_eq!( + artifact, + spec::Diagnosis { + verdict, + diagnosis_type, + hardware_info: Some(hardware_info.to_spec()), + subcomponent: Some(subcomponent.to_spec()), + message: Some(message), + source_location: Some(spec::SourceLocation { file, line }) + } + ); + + Ok(()) + } +} diff --git a/src/output/dut.rs b/src/output/dut.rs new file mode 100644 index 0000000..b7df26c --- /dev/null +++ b/src/output/dut.rs @@ -0,0 +1,709 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::collections::BTreeMap; + +use crate::output as tv; +use crate::output::trait_ext::{MapExt, VecExt}; +use crate::spec; + +/// TODO: docs +#[derive(Clone, Debug, PartialEq, Default)] +pub enum Ident { + #[default] + Auto, + Exact(String), +} + +/// TODO: docs +#[derive(Default, Debug, Clone, PartialEq)] +pub struct DutInfo { + id: String, + name: Option, + + platform_infos: Vec, + software_infos: Vec, + hardware_infos: Vec, + + metadata: BTreeMap, +} + +impl DutInfo { + pub fn builder(id: &str) -> DutInfoBuilder { + DutInfoBuilder::new(id) + } + + pub fn new(id: &str) -> DutInfo { + DutInfoBuilder::new(id).build() + } + + pub fn add_software_info(&mut self, info: SoftwareInfo) -> DutSoftwareInfo { + let id = match &info.id { + Ident::Auto => format!("{}_sw_{}", self.id, self.software_infos.len()), + Ident::Exact(v) => v.to_owned(), + }; + + let info = DutSoftwareInfo { id, source: info }; + self.software_infos.push(info.clone()); + info + } + + pub fn add_hardware_info(&mut self, info: HardwareInfo) -> DutHardwareInfo { + let id = match &info.id { + Ident::Auto => format!("{}_hw_{}", self.id, self.hardware_infos.len()), + Ident::Exact(v) => v.to_owned(), + }; + + let info = DutHardwareInfo { id, source: info }; + self.hardware_infos.push(info.clone()); + info + } + + pub fn software_info(&self, id: &str) -> Option<&DutSoftwareInfo> { + self.software_infos.iter().find(|si| si.id == id) + } + + pub fn hardware_info(&self, id: &str) -> Option<&DutHardwareInfo> { + self.hardware_infos.iter().find(|si| si.id == id) + } + + pub(crate) fn to_spec(&self) -> spec::DutInfo { + spec::DutInfo { + id: self.id.clone(), + name: self.name.clone(), + platform_infos: self.platform_infos.map_option(PlatformInfo::to_spec), + software_infos: self.software_infos.map_option(DutSoftwareInfo::to_spec), + hardware_infos: self.hardware_infos.map_option(DutHardwareInfo::to_spec), + metadata: self.metadata.option(), + } + } +} + +/// TODO: docs +#[derive(Default)] +pub struct DutInfoBuilder { + id: String, + name: Option, + platform_infos: Vec, + metadata: BTreeMap, +} + +impl DutInfoBuilder { + fn new(id: &str) -> Self { + DutInfoBuilder { + id: id.to_string(), + ..Default::default() + } + } + + pub fn name(mut self, value: &str) -> Self { + self.name = Some(value.to_string()); + self + } + + pub fn add_platform_info(mut self, platform_info: PlatformInfo) -> Self { + self.platform_infos.push(platform_info); + self + } + + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + pub fn build(self) -> DutInfo { + DutInfo { + id: self.id, + name: self.name, + platform_infos: self.platform_infos, + metadata: self.metadata, + ..Default::default() + } + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct Subcomponent { + subcomponent_type: Option, + name: String, + location: Option, + version: Option, + revision: Option, +} + +impl Subcomponent { + pub fn builder(name: &str) -> SubcomponentBuilder { + SubcomponentBuilder::new(name) + } + pub fn to_spec(&self) -> spec::Subcomponent { + spec::Subcomponent { + subcomponent_type: self.subcomponent_type.clone(), + name: self.name.clone(), + location: self.location.clone(), + version: self.version.clone(), + revision: self.revision.clone(), + } + } +} + +/// TODO: docs +#[derive(Debug)] +pub struct SubcomponentBuilder { + subcomponent_type: Option, + name: String, + location: Option, + version: Option, + revision: Option, +} + +impl SubcomponentBuilder { + fn new(name: &str) -> Self { + SubcomponentBuilder { + subcomponent_type: None, + name: name.to_string(), + location: None, + version: None, + revision: None, + } + } + pub fn subcomponent_type(mut self, value: spec::SubcomponentType) -> Self { + self.subcomponent_type = Some(value); + self + } + pub fn version(mut self, value: &str) -> Self { + self.version = Some(value.to_string()); + self + } + pub fn location(mut self, value: &str) -> Self { + self.location = Some(value.to_string()); + self + } + pub fn revision(mut self, value: &str) -> Self { + self.revision = Some(value.to_string()); + self + } + + pub fn build(self) -> Subcomponent { + Subcomponent { + subcomponent_type: self.subcomponent_type, + name: self.name, + location: self.location, + version: self.version, + revision: self.revision, + } + } +} + +/// TODO: docs +#[derive(Debug, Clone, PartialEq)] +pub struct PlatformInfo { + info: String, +} + +impl PlatformInfo { + pub fn new(info: &str) -> Self { + Self { + info: info.to_owned(), + } + } + + pub fn builder(info: &str) -> PlatformInfoBuilder { + PlatformInfoBuilder::new(info) + } + + pub fn to_spec(&self) -> spec::PlatformInfo { + spec::PlatformInfo { + info: self.info.clone(), + } + } +} + +/// TODO: docs +#[derive(Debug)] +pub struct PlatformInfoBuilder { + info: String, +} + +impl PlatformInfoBuilder { + fn new(info: &str) -> Self { + PlatformInfoBuilder { + info: info.to_string(), + } + } + + pub fn build(self) -> PlatformInfo { + PlatformInfo { info: self.info } + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct SoftwareInfo { + id: tv::Ident, + name: String, + version: Option, + revision: Option, + software_type: Option, + computer_system: Option, +} + +impl SoftwareInfo { + pub fn builder(name: &str) -> SoftwareInfoBuilder { + SoftwareInfoBuilder::new(name) + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct DutSoftwareInfo { + id: String, + source: SoftwareInfo, +} + +impl DutSoftwareInfo { + pub(crate) fn to_spec(&self) -> spec::SoftwareInfo { + let src = &self.source; + + spec::SoftwareInfo { + id: self.id.to_owned(), + name: src.name.clone(), + version: src.version.clone(), + revision: src.revision.clone(), + software_type: src.software_type.clone(), + computer_system: src.computer_system.clone(), + } + } +} + +impl PartialEq for DutSoftwareInfo { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +/// TODO: docs +#[derive(Debug, Default)] +pub struct SoftwareInfoBuilder { + id: tv::Ident, + name: String, + version: Option, + revision: Option, + software_type: Option, + computer_system: Option, +} + +impl SoftwareInfoBuilder { + fn new(name: &str) -> Self { + SoftwareInfoBuilder { + id: Ident::Auto, + name: name.to_string(), + ..Default::default() + } + } + + pub fn id(mut self, value: tv::Ident) -> Self { + self.id = value; + self + } + + pub fn version(mut self, value: &str) -> Self { + self.version = Some(value.to_string()); + self + } + + pub fn revision(mut self, value: &str) -> Self { + self.revision = Some(value.to_string()); + self + } + + pub fn software_type(mut self, value: spec::SoftwareType) -> Self { + self.software_type = Some(value); + self + } + + pub fn computer_system(mut self, value: &str) -> Self { + self.computer_system = Some(value.to_string()); + self + } + + pub fn build(self) -> SoftwareInfo { + SoftwareInfo { + id: self.id, + name: self.name, + version: self.version, + revision: self.revision, + software_type: self.software_type, + computer_system: self.computer_system, + } + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct HardwareInfo { + id: Ident, + name: String, + + version: Option, + revision: Option, + location: Option, + serial_no: Option, + part_no: Option, + // TODO: missing part_type + manufacturer: Option, + manufacturer_part_no: Option, + odata_id: Option, + computer_system: Option, + manager: Option, +} + +impl HardwareInfo { + pub fn builder(name: &str) -> HardwareInfoBuilder { + HardwareInfoBuilder::new(name) + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct DutHardwareInfo { + id: String, + source: HardwareInfo, +} + +impl DutHardwareInfo { + pub(crate) fn to_spec(&self) -> spec::HardwareInfo { + let src = &self.source; + + spec::HardwareInfo { + id: self.id.clone(), + name: src.name.clone(), + version: src.version.clone(), + revision: src.revision.clone(), + location: src.location.clone(), + serial_no: src.serial_no.clone(), + part_no: src.part_no.clone(), + manufacturer: src.manufacturer.clone(), + manufacturer_part_no: src.manufacturer_part_no.clone(), + odata_id: src.odata_id.clone(), + computer_system: src.computer_system.clone(), + manager: src.manager.clone(), + } + } +} + +impl PartialEq for DutHardwareInfo { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +/// TODO: docs +#[derive(Debug, Default)] +pub struct HardwareInfoBuilder { + id: tv::Ident, + name: String, + + version: Option, + revision: Option, + location: Option, + serial_no: Option, + part_no: Option, + manufacturer: Option, + manufacturer_part_no: Option, + odata_id: Option, + computer_system: Option, + manager: Option, +} + +impl HardwareInfoBuilder { + fn new(name: &str) -> Self { + HardwareInfoBuilder { + id: Ident::Auto, + name: name.to_string(), + ..Default::default() + } + } + + pub fn id(mut self, value: tv::Ident) -> Self { + self.id = value; + self + } + + pub fn version(mut self, value: &str) -> Self { + self.version = Some(value.to_string()); + self + } + + pub fn revision(mut self, value: &str) -> Self { + self.revision = Some(value.to_string()); + self + } + + pub fn location(mut self, value: &str) -> Self { + self.location = Some(value.to_string()); + self + } + + pub fn serial_no(mut self, value: &str) -> Self { + self.serial_no = Some(value.to_string()); + self + } + + pub fn part_no(mut self, value: &str) -> Self { + self.part_no = Some(value.to_string()); + self + } + + pub fn manufacturer(mut self, value: &str) -> Self { + self.manufacturer = Some(value.to_string()); + self + } + + pub fn manufacturer_part_no(mut self, value: &str) -> Self { + self.manufacturer_part_no = Some(value.to_string()); + self + } + + pub fn odata_id(mut self, value: &str) -> Self { + self.odata_id = Some(value.to_string()); + self + } + + pub fn computer_system(mut self, value: &str) -> Self { + self.computer_system = Some(value.to_string()); + self + } + + pub fn manager(mut self, value: &str) -> Self { + self.manager = Some(value.to_string()); + self + } + + pub fn build(self) -> HardwareInfo { + HardwareInfo { + id: self.id, + name: self.name, + version: self.version, + revision: self.revision, + location: self.location, + serial_no: self.serial_no, + part_no: self.part_no, + manufacturer: self.manufacturer, + manufacturer_part_no: self.manufacturer_part_no, + odata_id: self.odata_id, + computer_system: self.computer_system, + manager: self.manager, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec; + use anyhow::{bail, Result}; + + #[test] + fn test_dut_creation_from_builder_with_defaults() -> Result<()> { + let dut = DutInfo::builder("1234").build(); + assert_eq!(dut.id, "1234"); + Ok(()) + } + + #[test] + fn test_dut_builder() -> Result<()> { + let mut dut = DutInfo::builder("1234") + .name("dut") + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .add_platform_info(PlatformInfo::builder("platform_info").build()) + .build(); + + dut.add_software_info( + SoftwareInfo::builder("name") + .id(Ident::Exact("software_id".to_owned())) + .build(), + ); + + dut.add_hardware_info( + HardwareInfo::builder("name") + .id(Ident::Exact("hardware_id".to_owned())) + .build(), + ); + + let spec_dut = dut.to_spec(); + + assert_eq!(spec_dut.id, "1234"); + assert_eq!(spec_dut.name, Some("dut".to_owned())); + + match spec_dut.platform_infos { + Some(infos) => match infos.first() { + Some(info) => { + assert_eq!(info.info, "platform_info"); + } + _ => bail!("platform_infos is empty"), + }, + _ => bail!("platform_infos is missing"), + } + + match spec_dut.software_infos { + Some(infos) => match infos.first() { + Some(info) => { + assert_eq!(info.id, "software_id"); + } + _ => bail!("software_infos is empty"), + }, + _ => bail!("software_infos is missing"), + } + + match spec_dut.hardware_infos { + Some(infos) => match infos.first() { + Some(info) => { + assert_eq!(info.id, "hardware_id"); + } + _ => bail!("hardware_infos is empty"), + }, + _ => bail!("hardware_infos is missing"), + } + + match spec_dut.metadata { + Some(m) => { + assert_eq!(m["key"], "value"); + assert_eq!(m["key2"], "value2"); + } + _ => bail!("metadata is empty"), + } + + Ok(()) + } + + #[test] + fn test_hardware_info() -> Result<()> { + let mut dut = DutInfo::new("dut0"); + let info = dut.add_hardware_info( + HardwareInfo::builder("hardware_name") + .id(Ident::Exact("hardware_id".to_owned())) + .version("version") + .revision("revision") + .location("location") + .serial_no("serial_no") + .part_no("part_no") + .manufacturer("manufacturer") + .manufacturer_part_no("manufacturer_part_no") + .odata_id("odata_id") + .computer_system("computer_system") + .manager("manager") + .build(), + ); + + let spec_hwinfo = info.to_spec(); + + assert_eq!(spec_hwinfo.id, "hardware_id"); + assert_eq!(spec_hwinfo.name, "hardware_name"); + assert_eq!(spec_hwinfo.version, Some("version".to_owned())); + assert_eq!(spec_hwinfo.revision, Some("revision".to_owned())); + assert_eq!(spec_hwinfo.location, Some("location".to_owned())); + assert_eq!(spec_hwinfo.serial_no, Some("serial_no".to_owned())); + assert_eq!(spec_hwinfo.part_no, Some("part_no".to_owned())); + assert_eq!(spec_hwinfo.manufacturer, Some("manufacturer".to_owned())); + assert_eq!( + spec_hwinfo.manufacturer_part_no, + Some("manufacturer_part_no".to_owned()) + ); + assert_eq!(spec_hwinfo.odata_id, Some("odata_id".to_owned())); + assert_eq!( + spec_hwinfo.computer_system, + Some("computer_system".to_owned()) + ); + assert_eq!(spec_hwinfo.manager, Some("manager".to_owned())); + + Ok(()) + } + + #[test] + fn test_software_info() -> Result<()> { + let mut dut = DutInfo::new("dut0"); + let info = dut.add_software_info( + SoftwareInfo::builder("name") + .id(Ident::Exact("software_id".to_owned())) + .version("version") + .revision("revision") + .software_type(spec::SoftwareType::Application) + .computer_system("system") + .build(), + ); + + let spec_swinfo = info.to_spec(); + + assert_eq!(spec_swinfo.id, "software_id"); + assert_eq!(spec_swinfo.name, "name"); + assert_eq!(spec_swinfo.version, Some("version".to_owned())); + assert_eq!(spec_swinfo.revision, Some("revision".to_owned())); + assert_eq!( + spec_swinfo.software_type, + Some(spec::SoftwareType::Application) + ); + assert_eq!(spec_swinfo.computer_system, Some("system".to_owned())); + + Ok(()) + } + + #[test] + fn test_platform_info_new() -> Result<()> { + let info = PlatformInfo::new("info"); + assert_eq!(info.to_spec().info, "info"); + Ok(()) + } + + #[test] + fn test_platform_info_builder() -> Result<()> { + let info = PlatformInfo::builder("info").build(); + assert_eq!(info.to_spec().info, "info"); + Ok(()) + } + + #[test] + fn test_subcomponent() -> Result<()> { + let sub = Subcomponent::builder("sub_name") + .subcomponent_type(spec::SubcomponentType::Asic) + .version("version") + .location("location") + .revision("revision") + .build(); + + let spec_subcomponent = sub.to_spec(); + + assert_eq!(spec_subcomponent.name, "sub_name"); + assert_eq!(spec_subcomponent.version, Some("version".to_owned())); + assert_eq!(spec_subcomponent.revision, Some("revision".to_owned())); + assert_eq!(spec_subcomponent.location, Some("location".to_owned())); + assert_eq!( + spec_subcomponent.subcomponent_type, + Some(spec::SubcomponentType::Asic) + ); + + Ok(()) + } + + /// 100% coverage test, since there's no way to exclude code + #[test] + fn test_infos_eq() -> Result<()> { + let sw = DutSoftwareInfo { + id: "sw0".to_owned(), + source: SoftwareInfo::builder("sw").build(), + }; + assert_eq!(sw, sw); + + let hw = DutHardwareInfo { + id: "hw0".to_owned(), + source: HardwareInfo::builder("hw").build(), + }; + assert_eq!(hw, hw); + + Ok(()) + } +} diff --git a/src/output/emitter.rs b/src/output/emitter.rs new file mode 100644 index 0000000..8913e13 --- /dev/null +++ b/src/output/emitter.rs @@ -0,0 +1,181 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::io; +use std::sync::atomic::{self, Ordering}; +use std::sync::Arc; + +use unwrap_infallible::UnwrapInfallible; + +use crate::output::{ + config, + writer::{self, WriterType}, +}; +use crate::spec; + +pub struct JsonEmitter { + timestamp_provider: Box, + writer: writer::WriterType, + seqno: Arc, +} + +impl JsonEmitter { + pub fn new( + timestamp_provider: Box, + writer: writer::WriterType, + ) -> Self { + JsonEmitter { + timestamp_provider, + writer, + seqno: Arc::new(atomic::AtomicU64::new(0)), + } + } + + fn incr_seqno(&self) -> u64 { + self.seqno.fetch_add(1, Ordering::AcqRel) + } + + async fn emit_version(&self) -> Result<(), io::Error> { + let s = self.serialize(&spec::RootImpl::SchemaVersion( + spec::SchemaVersion::default(), + )); + + self.write(s).await + } + + fn serialize(&self, root: &spec::RootImpl) -> String { + let root = spec::Root { + artifact: root.clone(), + timestamp: self.timestamp_provider.now(), + seqno: self.incr_seqno(), + }; + + serde_json::json!(root).to_string() + } + + async fn write(&self, s: String) -> Result<(), io::Error> { + match &self.writer { + WriterType::File(file) => file.write(&s).await?, + WriterType::Stdout(stdout) => stdout.write(&s).await.unwrap_infallible(), + WriterType::Buffer(buffer) => buffer.write(&s).await.unwrap_infallible(), + + WriterType::Custom(custom) => custom.write(&s).await?, + } + + Ok(()) + } + + pub fn timestamp_provider(&self) -> &(dyn config::TimestampProvider + Send + Sync + 'static) { + &*self.timestamp_provider + } + + pub async fn emit(&self, root: &spec::RootImpl) -> Result<(), io::Error> { + if self.seqno.load(Ordering::Acquire) == 0 { + self.emit_version().await?; + } + + self.write(self.serialize(root)).await + } +} + +#[cfg(test)] +mod tests { + use anyhow::{anyhow, Result}; + use assert_json_diff::assert_json_eq; + use serde_json::json; + use tokio::sync::Mutex; + + use super::*; + + pub struct NullTimestampProvider {} + + impl NullTimestampProvider { + // warn: linter is wrong here, this is used in a serde_json::json! block + #[allow(dead_code)] + pub const FORMATTED: &str = "1970-01-01T00:00:00.000Z"; + } + + impl config::TimestampProvider for NullTimestampProvider { + fn now(&self) -> chrono::DateTime { + chrono::DateTime::from_timestamp_nanos(0).with_timezone(&chrono_tz::UTC) + } + } + + #[tokio::test] + async fn test_emit_using_buffer_writer() -> Result<()> { + let expected = json!({ + "schemaVersion": { + "major": spec::SPEC_VERSION.0, + "minor": spec::SPEC_VERSION.1, + }, + "sequenceNumber": 0, + "timestamp": NullTimestampProvider::FORMATTED, + }); + + let buffer = Arc::new(Mutex::new(vec![])); + let writer = writer::BufferWriter::new(buffer.clone()); + let emitter = JsonEmitter::new( + Box::new(NullTimestampProvider {}), + writer::WriterType::Buffer(writer), + ); + + emitter + .emit(&spec::RootImpl::SchemaVersion( + spec::SchemaVersion::default(), + )) + .await?; + + let deserialized = serde_json::from_str::( + buffer.lock().await.first().ok_or(anyhow!("no outputs"))?, + )?; + assert_json_eq!(deserialized, expected); + + Ok(()) + } + + #[tokio::test] + async fn test_sequence_number_increments_at_each_call() -> Result<()> { + let expected_1 = json!({ + "schemaVersion": { + "major": spec::SPEC_VERSION.0, + "minor": spec::SPEC_VERSION.1, + }, + "sequenceNumber": 0, + "timestamp": NullTimestampProvider::FORMATTED, + }); + let expected_2 = json!({ + "schemaVersion": { + "major": spec::SPEC_VERSION.0, + "minor": spec::SPEC_VERSION.1, + }, + "sequenceNumber": 1, + "timestamp": NullTimestampProvider::FORMATTED, + }); + + let buffer = Arc::new(Mutex::new(vec![])); + let writer = writer::BufferWriter::new(buffer.clone()); + let emitter = JsonEmitter::new( + Box::new(NullTimestampProvider {}), + writer::WriterType::Buffer(writer), + ); + + let version = spec::RootImpl::SchemaVersion(spec::SchemaVersion::default()); + emitter.emit(&version).await?; + emitter.emit(&version).await?; + + let deserialized = serde_json::from_str::( + buffer.lock().await.first().ok_or(anyhow!("no outputs"))?, + )?; + assert_json_eq!(deserialized, expected_1); + + let deserialized = serde_json::from_str::( + buffer.lock().await.get(1).ok_or(anyhow!("no outputs"))?, + )?; + assert_json_eq!(deserialized, expected_2); + + Ok(()) + } +} diff --git a/src/output/error.rs b/src/output/error.rs new file mode 100644 index 0000000..764a96c --- /dev/null +++ b/src/output/error.rs @@ -0,0 +1,190 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use crate::output as tv; +use crate::spec; +use tv::{dut, trait_ext::VecExt, DutSoftwareInfo}; + +/// TODO: docs +#[derive(Clone)] +pub struct Error { + symptom: String, + message: Option, + software_infos: Vec, + source_location: Option, +} + +impl Error { + pub fn builder(symptom: &str) -> ErrorBuilder { + ErrorBuilder::new(symptom) + } + + pub fn to_artifact(&self) -> spec::Error { + spec::Error { + symptom: self.symptom.clone(), + message: self.message.clone(), + software_infos: self.software_infos.map_option(DutSoftwareInfo::to_spec), + source_location: self.source_location.clone(), + } + } +} + +/// TODO: docs +#[derive(Debug, Default)] +pub struct ErrorBuilder { + symptom: String, + message: Option, + software_infos: Vec, + source_location: Option, +} + +impl ErrorBuilder { + fn new(symptom: &str) -> Self { + ErrorBuilder { + symptom: symptom.to_string(), + ..Default::default() + } + } + + pub fn message(mut self, value: &str) -> Self { + self.message = Some(value.to_string()); + self + } + + pub fn source(mut self, file: &str, line: i32) -> Self { + self.source_location = Some(spec::SourceLocation { + file: file.to_string(), + line, + }); + self + } + + pub fn add_software_info(mut self, software_info: &dut::DutSoftwareInfo) -> Self { + self.software_infos.push(software_info.clone()); + self + } + + pub fn build(self) -> Error { + Error { + symptom: self.symptom, + message: self.message, + source_location: self.source_location, + software_infos: self.software_infos, + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use super::*; + use crate::output as tv; + use crate::spec; + use tv::dut; + use tv::Ident; + + #[test] + fn test_error_output_as_test_run_descendant_to_artifact() -> Result<()> { + let mut dut = dut::DutInfo::new("dut0"); + let sw_info = dut.add_software_info(dut::SoftwareInfo::builder("name").build()); + + let error = Error::builder("symptom") + .message("") + .add_software_info(&sw_info) + .source("", 1) + .build(); + + let artifact = error.to_artifact(); + assert_eq!( + artifact, + spec::Error { + symptom: error.symptom.clone(), + message: error.message.clone(), + software_infos: Some(vec![sw_info.to_spec()]), + source_location: error.source_location.clone(), + } + ); + + Ok(()) + } + + #[test] + fn test_error_output_as_test_step_descendant_to_artifact() -> Result<()> { + let mut dut = dut::DutInfo::new("dut0"); + let sw_info = dut.add_software_info(dut::SoftwareInfo::builder("name").build()); + + let error = Error::builder("symptom") + .message("") + .add_software_info(&sw_info) + .source("", 1) + .build(); + + let artifact = error.to_artifact(); + assert_eq!( + artifact, + spec::Error { + symptom: error.symptom.clone(), + message: error.message.clone(), + software_infos: Some(vec![sw_info.to_spec()]), + source_location: error.source_location.clone(), + } + ); + + Ok(()) + } + + #[test] + fn test_error_with_multiple_software() -> Result<()> { + let expected_run = json!({ + "message": "message", + "softwareInfoIds": [ + "software_id", + "software_id" + ], + "sourceLocation": { + "file": "file.rs", + "line": 1 + }, + "symptom": "symptom" + }); + let expected_step = json!({ + "message": "message", + "softwareInfoIds": [ + "software_id", + "software_id" + ], + "sourceLocation": {"file":"file.rs","line":1}, + "symptom":"symptom" + }); + + let mut dut = dut::DutInfo::new("dut0"); + let sw_info = dut.add_software_info( + dut::SoftwareInfo::builder("name") + .id(Ident::Exact("software_id".to_owned())) + .build(), + ); + + let error = ErrorBuilder::new("symptom") + .message("message") + .source("file.rs", 1) + .add_software_info(&sw_info) + .add_software_info(&sw_info) + .build(); + + let spec_error = error.to_artifact(); + let actual = json!(spec_error); + assert_json_eq!(actual, expected_run); + + let spec_error = error.to_artifact(); + let actual = json!(spec_error); + assert_json_eq!(actual, expected_step); + + Ok(()) + } +} diff --git a/src/output/file.rs b/src/output/file.rs new file mode 100644 index 0000000..8c72dea --- /dev/null +++ b/src/output/file.rs @@ -0,0 +1,301 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::collections::BTreeMap; + +use mime; + +use crate::output::{self as tv, trait_ext::MapExt}; +use crate::spec; + +/// This structure represents a File message. +/// +/// ref: +/// +/// # Examples +/// +/// ## Create a File object with the `new` method +/// +/// ``` +/// # use ocptv::output::*; +/// let uri = Uri::parse("file:///tmp/foo").unwrap(); +/// let file = File::new("name", uri); +/// ``` +/// +/// ## Create a File object with the `builder` method +/// +/// ``` +/// # use ocptv::output::*; +/// # use std::str::FromStr; +/// let uri = Uri::parse("file:///tmp/foo").unwrap(); +/// let file = File::builder("name", uri) +/// .is_snapshot(true) +/// .description("description") +/// .content_type(mime::TEXT_PLAIN) +/// .add_metadata("key", "value") +/// .build(); +/// ``` +pub struct File { + name: String, + uri: tv::Uri, + is_snapshot: bool, + description: Option, + content_type: Option, + metadata: BTreeMap, +} + +impl File { + /// Builds a new File object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let file = File::new("name", uri); + /// ``` + pub fn new(name: &str, uri: tv::Uri) -> Self { + File { + name: name.to_owned(), + uri, + is_snapshot: false, + description: None, + content_type: None, + metadata: BTreeMap::new(), + } + } + + /// Builds a new File object using [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// # use std::str::FromStr; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let file = File::builder("name", uri) + /// .description("description") + /// .content_type(mime::TEXT_PLAIN) + /// .add_metadata("key", "value") + /// .build(); + /// ``` + pub fn builder(name: &str, uri: tv::Uri) -> FileBuilder { + FileBuilder::new(name, uri) + } + + /// Creates an artifact from a File object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let file = File::new("name", uri); + /// let _ = file.to_artifact(); + /// ``` + pub fn to_artifact(&self) -> spec::File { + spec::File { + name: self.name.clone(), + uri: self.uri.as_str().to_owned(), + is_snapshot: self.is_snapshot, + description: self.description.clone(), + content_type: self.content_type.as_ref().map(|ct| ct.to_string()), + metadata: self.metadata.option(), + } + } +} + +/// This structure builds a [`File`] object. +/// +/// # Examples +/// +/// ``` +/// # use ocptv::output::*; +/// # use std::str::FromStr; +/// let uri = Uri::parse("file:///tmp/foo").unwrap(); +/// let builder = File::builder("name", uri) +/// .description("description") +/// .content_type(mime::TEXT_PLAIN) +/// .add_metadata("key", "value"); +/// let file = builder.build(); +/// ``` +pub struct FileBuilder { + name: String, + uri: tv::Uri, + is_snapshot: bool, + description: Option, + content_type: Option, + + metadata: BTreeMap, +} + +impl FileBuilder { + fn new(name: &str, uri: tv::Uri) -> Self { + FileBuilder { + name: name.to_string(), + uri, + is_snapshot: false, + description: None, + content_type: None, + metadata: BTreeMap::new(), + } + } + + /// Set the is_snapshot attribute in a [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let builder = File::builder("name", uri) + /// .is_snapshot(true); + /// ``` + pub fn is_snapshot(mut self, value: bool) -> FileBuilder { + self.is_snapshot = value; + self + } + + /// Add a description to a [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let builder = File::builder("name", uri) + /// .description("description"); + /// ``` + pub fn description(mut self, description: &str) -> FileBuilder { + self.description = Some(description.to_owned()); + self + } + + /// Add a content_type to a [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// # use std::str::FromStr; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let builder = File::builder("name", uri) + /// .content_type(mime::TEXT_PLAIN); + /// ``` + pub fn content_type(mut self, content_type: mime::Mime) -> FileBuilder { + self.content_type = Some(content_type); + self + } + + /// Add custom metadata to a [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let builder = File::builder("name", uri) + /// .add_metadata("key", "value"); + /// ``` + pub fn add_metadata>(mut self, key: &str, value: V) -> FileBuilder { + self.metadata.insert(key.to_string(), value.into()); + self + } + + /// Builds a [`File`] object from a [`FileBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let builder = File::builder("name", uri); + /// let file = builder.build(); + /// ``` + pub fn build(self) -> File { + File { + name: self.name, + uri: self.uri, + is_snapshot: self.is_snapshot, + description: self.description, + content_type: self.content_type, + metadata: self.metadata, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output as tv; + use crate::spec; + use anyhow::Result; + use maplit::btreemap; + use maplit::convert_args; + + #[test] + fn test_file_as_test_step_descendant_to_artifact() -> Result<()> { + let name = "name".to_owned(); + let uri = tv::Uri::parse("file:///tmp/foo")?; + let is_snapshot = false; + let file = File::new(&name, uri.clone()); + + let artifact = file.to_artifact(); + + assert_eq!( + artifact, + spec::File { + name, + uri: uri.as_str().to_owned(), + is_snapshot, + description: None, + content_type: None, + metadata: None, + } + ); + + Ok(()) + } + + #[test] + fn test_file_builder_as_test_step_descendant_to_artifact() -> Result<()> { + let name = "name".to_owned(); + let uri = tv::Uri::parse("file:///tmp/foo")?; + let is_snapshot = false; + let description = "description".to_owned(); + let content_type = mime::TEXT_PLAIN; + let meta_key = "key"; + let meta_value = tv::Value::from("value"); + let metadata = convert_args!(btreemap!( + meta_key => meta_value.clone(), + )); + + let file = File::builder(&name, uri.clone()) + .is_snapshot(is_snapshot) + .description(&description) + .content_type(content_type.clone()) + .add_metadata(meta_key, meta_value.clone()) + .add_metadata(meta_key, meta_value.clone()) + .build(); + + let artifact = file.to_artifact(); + assert_eq!( + artifact, + spec::File { + name, + uri: uri.as_str().to_owned(), + is_snapshot, + description: Some(description), + content_type: Some(content_type.to_string()), + metadata: Some(metadata), + } + ); + + Ok(()) + } +} diff --git a/src/output/log.rs b/src/output/log.rs new file mode 100644 index 0000000..b5e933a --- /dev/null +++ b/src/output/log.rs @@ -0,0 +1,113 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use crate::spec; + +/// TODO: docs +pub struct Log { + severity: spec::LogSeverity, + message: String, + source_location: Option, +} + +impl Log { + pub fn builder(message: &str) -> LogBuilder { + LogBuilder::new(message) + } + + pub fn to_artifact(&self) -> spec::Log { + spec::Log { + severity: self.severity.clone(), + message: self.message.clone(), + source_location: self.source_location.clone(), + } + } +} + +/// TODO: docs +#[derive(Debug)] +pub struct LogBuilder { + severity: spec::LogSeverity, + message: String, + source_location: Option, +} + +impl LogBuilder { + fn new(message: &str) -> Self { + LogBuilder { + severity: spec::LogSeverity::Info, + message: message.to_string(), + source_location: None, + } + } + + pub fn severity(mut self, value: spec::LogSeverity) -> Self { + self.severity = value; + self + } + + pub fn source(mut self, file: &str, line: i32) -> Self { + self.source_location = Some(spec::SourceLocation { + file: file.to_string(), + line, + }); + self + } + + pub fn build(self) -> Log { + Log { + severity: self.severity, + message: self.message, + source_location: self.source_location, + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + use crate::spec; + + #[test] + fn test_log_output_as_test_run_descendant_to_artifact() -> Result<()> { + let log = Log::builder("test") + .severity(spec::LogSeverity::Info) + .build(); + + let artifact = log.to_artifact(); + assert_eq!( + artifact, + spec::Log { + severity: log.severity.clone(), + message: log.message.clone(), + source_location: log.source_location.clone(), + }, + ); + + Ok(()) + } + + #[test] + fn test_log_output_as_test_step_descendant_to_artifact() -> Result<()> { + let log = Log::builder("test") + .severity(spec::LogSeverity::Info) + .build(); + + let artifact = log.to_artifact(); + assert_eq!( + artifact, + spec::Log { + severity: log.severity.clone(), + message: log.message.clone(), + source_location: log.source_location.clone(), + } + ); + + Ok(()) + } +} diff --git a/src/output/macros.rs b/src/output/macros.rs new file mode 100644 index 0000000..7e66146 --- /dev/null +++ b/src/output/macros.rs @@ -0,0 +1,177 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +//! OCPTV library macros +//! +//! This module contains a set of macros which are exported from the ocptv +//! library. + +/// Emit an artifact of type Error. +/// +/// ref: +/// +/// Equivalent to the [`$crate::StartedTestRun::error_with_details`] method. +/// +/// It accepts both a symptom and a message, or just a symptom. +/// Information about the source file and line number is automatically added. +/// +/// # Examples +/// +/// ## Passing only symptom +/// +/// ```rust +/// # tokio_test::block_on(async { +/// # use ocptv::output::*; +/// use ocptv::ocptv_error; +/// +/// let dut = DutInfo::new("my_dut"); +/// let test_run = TestRun::new("run_name", "1.0").start(dut).await?; +/// ocptv_error!(test_run, "symptom"); +/// test_run.end(TestStatus::Complete, TestResult::Pass).await?; +/// +/// # Ok::<(), OcptvError>(()) +/// # }); +/// ``` +/// +/// ## Passing both symptom and message +/// +/// ```rust +/// # tokio_test::block_on(async { +/// # use ocptv::output::*; +/// +/// use ocptv::ocptv_error; +/// +/// let dut = DutInfo::new("my_dut"); +/// let test_run = TestRun::new("run_name", "1.0").start(dut).await?; +/// ocptv_error!(test_run, "symptom", "Error message"); +/// test_run.end(TestStatus::Complete, TestResult::Pass).await?; +/// +/// # Ok::<(), OcptvError>(()) +/// # }); +/// ``` +#[macro_export] +macro_rules! ocptv_error { + ($runner:expr, $symptom:expr, $msg:expr) => { + $runner.add_error_detail( + $crate::output::Error::builder($symptom) + .message($msg) + .source(file!(), line!() as i32) + .build(), + ) + }; + + ($runner:expr, $symptom:expr) => { + $runner.add_error_detail( + $crate::output::Error::builder($symptom) + .source(file!(), line!() as i32) + .build(), + ) + }; +} + +macro_rules! ocptv_log { + ($name:ident, $severity:path) => { + /// Emit an artifact of type Log. + /// + /// ref: + /// + /// Equivalent to the [`$crate::StartedTestRun::log_with_details`] method. + /// + /// They accept message as only parameter. + /// Information about the source file and line number is automatically added. + /// + /// There is one macro for each severity level: DEBUG, INFO, WARNING, ERROR, and FATAL. + /// + /// # Examples + /// + /// ## DEBUG + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// use ocptv::ocptv_log_debug; + /// + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("run_name", "1.0").start(dut).await?; + /// ocptv_log_debug!(run, "Log message"); + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + #[macro_export] + macro_rules! $name { + ($artifact:expr, $msg:expr) => { + $artifact.add_log_detail( + $crate::output::Log::builder($msg) + .severity($severity) + .source(file!(), line!() as i32) + .build(), + ) + }; + } + }; +} + +ocptv_log!(ocptv_log_debug, ocptv::output::LogSeverity::Debug); +ocptv_log!(ocptv_log_info, ocptv::output::LogSeverity::Info); +ocptv_log!(ocptv_log_warning, ocptv::output::LogSeverity::Warning); +ocptv_log!(ocptv_log_error, ocptv::output::LogSeverity::Error); +ocptv_log!(ocptv_log_fatal, ocptv::output::LogSeverity::Fatal); + +macro_rules! ocptv_diagnosis { + ($name:ident, $diagnosis_type:path) => { + /// Emit an artifact of type Diagnosis. + /// + /// ref: + /// + /// Equivalent to the [`$crate::StartedTestStep::diagnosis_with_details`] method. + /// + /// They accept verdict as only parameter. + /// Information about the source file and line number is automatically added. + /// + /// There is one macro for each DiagnosisType variant: Pass, Fail, Unknown. + /// + /// # Examples + /// + /// ## DEBUG + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// use ocptv::ocptv_diagnosis_pass; + /// + /// let dut = DutInfo::new("my dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// ocptv_diagnosis_pass!(step, "verdict"); + /// step.end(TestStatus::Complete).await?; + /// + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + #[macro_export] + macro_rules! $name { + ($artifact:expr, $verdict:expr) => { + $artifact.add_diagnosis_detail( + $crate::output::Diagnosis::builder($verdict, $diagnosis_type) + .source(file!(), line!() as i32) + .build(), + ) + }; + } + }; +} + +ocptv_diagnosis!(ocptv_diagnosis_pass, ocptv::output::DiagnosisType::Pass); +ocptv_diagnosis!(ocptv_diagnosis_fail, ocptv::output::DiagnosisType::Fail); +ocptv_diagnosis!( + ocptv_diagnosis_unknown, + ocptv::output::DiagnosisType::Unknown +); diff --git a/src/output/measure.rs b/src/output/measure.rs new file mode 100644 index 0000000..030a323 --- /dev/null +++ b/src/output/measure.rs @@ -0,0 +1,832 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::collections::BTreeMap; +use std::future::Future; +use std::sync::atomic::{self, Ordering}; +use std::sync::Arc; + +use delegate::delegate; + +use crate::output as tv; +use crate::output::trait_ext::{MapExt, VecExt}; +use crate::spec; +use tv::{dut, step, Ident}; + +/// The measurement series. +/// A Measurement Series is a time-series list of measurements. +/// +/// ref: +pub struct MeasurementSeries { + id: String, + detail: MeasurementSeriesDetail, + + emitter: Arc, +} + +impl MeasurementSeries { + // note: this object is crate public but users should only construct + // instances through the `StartedTestStep.add_measurement_series_*` apis + pub(crate) fn new( + series_id: &str, + info: MeasurementSeriesDetail, + emitter: Arc, + ) -> Self { + Self { + id: series_id.to_owned(), + detail: info, + emitter, + } + } + + /// Starts the measurement series. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let series = step.add_measurement_series("name"); + /// series.start().await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn start(self) -> Result { + let info = &self.detail; + + let start = spec::MeasurementSeriesStart { + name: info.name.clone(), + unit: info.unit.clone(), + series_id: self.id.clone(), + validators: info.validators.map_option(Validator::to_spec), + hardware_info: info + .hardware_info + .as_ref() + .map(dut::DutHardwareInfo::to_spec), + subcomponent: info.subcomponent.as_ref().map(dut::Subcomponent::to_spec), + metadata: info.metadata.option(), + }; + + self.emitter + .emit(&spec::TestStepArtifactImpl::MeasurementSeriesStart(start)) + .await?; + + Ok(StartedMeasurementSeries { + parent: self, + seqno: Arc::new(atomic::AtomicU64::new(0)), + }) + } + + /// Builds a scope in the [`MeasurementSeries`] object, taking care of starting and + /// ending it. View [`MeasurementSeries::start`] and [`StartedMeasurementSeries::end`] methods. + /// After the scope is constructed, additional objects may be added to it. + /// This is the preferred usage for the [`MeasurementSeries`], since it guarantees + /// all the messages are emitted between the start and end messages, the order + /// is respected and no messages is lost. + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use futures::FutureExt; + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let series = step.add_measurement_series("name"); + /// series.scope(|s| { + /// async move { + /// s.add_measurement(60).await?; + /// s.add_measurement(70).await?; + /// s.add_measurement(80).await?; + /// Ok(()) + /// }.boxed() + /// }).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn scope(self, func: F) -> Result<(), tv::OcptvError> + where + R: Future> + Send + 'static, + F: FnOnce(ScopedMeasurementSeries) -> R + Send + 'static, + { + let series = Arc::new(self.start().await?); + func(ScopedMeasurementSeries { + series: Arc::clone(&series), + }) + .await?; + series.end_impl().await?; + + Ok(()) + } +} + +/// TODO: docs +pub struct StartedMeasurementSeries { + parent: MeasurementSeries, + + seqno: Arc, +} + +impl StartedMeasurementSeries { + fn incr_seqno(&self) -> u64 { + self.seqno.fetch_add(1, Ordering::AcqRel) + } + + // note: keep the self-consuming method for crate api, but use this one internally, + // since `StartedMeasurementSeries::end` only needs to take ownership for syntactic reasons + async fn end_impl(&self) -> Result<(), tv::OcptvError> { + let end = spec::MeasurementSeriesEnd { + series_id: self.parent.id.clone(), + total_count: self.seqno.load(Ordering::Acquire), + }; + + self.parent + .emitter + .emit(&spec::TestStepArtifactImpl::MeasurementSeriesEnd(end)) + .await?; + + Ok(()) + } + + /// Ends the measurement series. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let series = step.add_measurement_series("name").start().await?; + /// series.end().await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn end(self) -> Result<(), tv::OcptvError> { + self.end_impl().await + } + + /// Adds a measurement element to the measurement series. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let series = step.add_measurement_series("name").start().await?; + /// series.add_measurement(60).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_measurement>( + &self, + value: V, + ) -> Result<(), tv::OcptvError> { + self.add_measurement_detail(MeasurementElementDetail { + value: value.into(), + ..Default::default() + }) + .await + } + + /// Adds a measurement element to the measurement series. + /// This method accepts a full set of details for the measurement element. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let series = step.add_measurement_series("name").start().await?; + /// let elem = MeasurementElementDetail::builder(60).add_metadata("key", "value").build(); + /// series.add_measurement_detail(elem).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_measurement_detail( + &self, + element: MeasurementElementDetail, + ) -> Result<(), tv::OcptvError> { + let element = spec::MeasurementSeriesElement { + index: self.incr_seqno(), + value: element.value, + timestamp: element + .timestamp + .unwrap_or(self.parent.emitter.timestamp_provider().now()), + series_id: self.parent.id.clone(), + metadata: element.metadata.option(), + }; + + self.parent + .emitter + .emit(&spec::TestStepArtifactImpl::MeasurementSeriesElement( + element, + )) + .await?; + + Ok(()) + } +} + +/// TODO: docs +pub struct ScopedMeasurementSeries { + series: Arc, +} + +impl ScopedMeasurementSeries { + delegate! { + to self.series { + pub async fn add_measurement>(&self, value: V) -> Result<(), tv::OcptvError>; + pub async fn add_measurement_detail( + &self, + element: MeasurementElementDetail, + ) -> Result<(), tv::OcptvError>; + } + } +} + +/// TODO: docs +#[derive(Default)] +pub struct MeasurementElementDetail { + value: tv::Value, + timestamp: Option>, + + metadata: BTreeMap, +} + +impl MeasurementElementDetail { + pub fn builder>(value: V) -> MeasurementElementDetailBuilder { + MeasurementElementDetailBuilder::new(value.into()) + } +} + +/// TODO: docs +#[derive(Default)] +pub struct MeasurementElementDetailBuilder { + value: tv::Value, + timestamp: Option>, + + metadata: BTreeMap, +} + +impl MeasurementElementDetailBuilder { + fn new(value: tv::Value) -> Self { + Self { + value, + ..Default::default() + } + } + + pub fn timestamp(mut self, value: chrono::DateTime) -> Self { + self.timestamp = Some(value); + self + } + + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + pub fn build(self) -> MeasurementElementDetail { + MeasurementElementDetail { + value: self.value, + timestamp: self.timestamp, + metadata: self.metadata, + } + } +} + +/// TODO: docs +#[derive(Clone)] +pub struct Validator { + name: Option, + validator_type: spec::ValidatorType, + value: tv::Value, + metadata: BTreeMap, +} + +impl Validator { + pub fn builder>( + validator_type: spec::ValidatorType, + value: V, + ) -> ValidatorBuilder { + ValidatorBuilder::new(validator_type, value.into()) + } + + pub fn to_spec(&self) -> spec::Validator { + spec::Validator { + name: self.name.clone(), + validator_type: self.validator_type.clone(), + value: self.value.clone(), + metadata: self.metadata.option(), + } + } +} + +/// TODO: docs +#[derive(Debug)] +pub struct ValidatorBuilder { + name: Option, + validator_type: spec::ValidatorType, + value: tv::Value, + + metadata: BTreeMap, +} + +impl ValidatorBuilder { + fn new(validator_type: spec::ValidatorType, value: tv::Value) -> Self { + ValidatorBuilder { + validator_type, + value, + name: None, + metadata: BTreeMap::new(), + } + } + + pub fn name(mut self, value: &str) -> Self { + self.name = Some(value.to_string()); + self + } + + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + pub fn build(self) -> Validator { + Validator { + name: self.name, + validator_type: self.validator_type, + value: self.value, + metadata: self.metadata, + } + } +} + +/// This structure represents a Measurement message. +/// ref: +/// +/// # Examples +/// +/// ## Create a Measurement object with the `new` method +/// +/// ``` +/// # use ocptv::output::*; +/// let measurement = Measurement::new("name", 50); +/// ``` +/// +/// ## Create a Measurement object with the `builder` method +/// +/// ``` +/// # use ocptv::output::*; +/// let mut dut = DutInfo::new("dut0"); +/// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); +/// +/// let measurement = Measurement::builder("name", 50) +/// .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) +/// .add_metadata("key", "value") +/// .hardware_info(&hw_info) +/// .subcomponent(Subcomponent::builder("name").build()) +/// .build(); +/// ``` +#[derive(Default)] +pub struct Measurement { + name: String, + + value: tv::Value, + unit: Option, + validators: Vec, + + hardware_info: Option, + subcomponent: Option, + + metadata: BTreeMap, +} + +impl Measurement { + /// Builds a new Measurement object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let measurement = Measurement::new("name", 50); + /// ``` + pub fn new>(name: &str, value: V) -> Self { + Measurement { + name: name.to_string(), + value: value.into(), + ..Default::default() + } + } + + /// Builds a new Measurement object using [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// + /// let mut dut = DutInfo::new("dut0"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + /// + /// let measurement = Measurement::builder("name", 50) + /// .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) + /// .add_metadata("key", "value") + /// .hardware_info(&hw_info) + /// .subcomponent(Subcomponent::builder("name").build()) + /// .build(); + /// ``` + pub fn builder>(name: &str, value: V) -> MeasurementBuilder { + MeasurementBuilder::new(name, value.into()) + } + + /// Creates an artifact from a Measurement object. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let measurement = Measurement::new("name", 50); + /// let _ = measurement.to_artifact(); + /// ``` + pub fn to_artifact(&self) -> spec::Measurement { + spec::Measurement { + name: self.name.clone(), + unit: self.unit.clone(), + value: self.value.clone(), + validators: self.validators.map_option(Validator::to_spec), + hardware_info: self + .hardware_info + .as_ref() + .map(dut::DutHardwareInfo::to_spec), + subcomponent: self + .subcomponent + .as_ref() + .map(|subcomponent| subcomponent.to_spec()), + metadata: self.metadata.option(), + } + } +} + +/// This structure builds a [`Measurement`] object. +/// +/// # Examples +/// +/// ``` +/// # use ocptv::output::*; +/// let mut dut = DutInfo::new("dut0"); +/// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); +/// +/// let builder = Measurement::builder("name", 50) +/// .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) +/// .add_metadata("key", "value") +/// .hardware_info(&hw_info) +/// .subcomponent(Subcomponent::builder("name").build()); +/// let measurement = builder.build(); +/// ``` +#[derive(Default)] +pub struct MeasurementBuilder { + name: String, + + value: tv::Value, + unit: Option, + validators: Vec, + + hardware_info: Option, + subcomponent: Option, + + metadata: BTreeMap, +} + +impl MeasurementBuilder { + fn new(name: &str, value: tv::Value) -> Self { + MeasurementBuilder { + name: name.to_string(), + value, + ..Default::default() + } + } + + /// Add a [`Validator`] to a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Measurement::builder("name", 50) + /// .add_validator(Validator::builder(ValidatorType::Equal, 30).build()); + /// ``` + pub fn add_validator(mut self, validator: Validator) -> Self { + self.validators.push(validator.clone()); + self + } + + /// Add a [`tv::HardwareInfo`] to a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("dut0"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + /// + /// let builder = Measurement::builder("name", 50) + /// .hardware_info(&hw_info); + /// ``` + pub fn hardware_info(mut self, hardware_info: &dut::DutHardwareInfo) -> Self { + self.hardware_info = Some(hardware_info.clone()); + self + } + + /// Add a [`tv::Subcomponent`] to a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Measurement::builder("name", 50) + /// .subcomponent(Subcomponent::builder("name").build()); + /// ``` + pub fn subcomponent(mut self, subcomponent: dut::Subcomponent) -> Self { + self.subcomponent = Some(subcomponent); + self + } + + /// Add custom metadata to a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = + /// Measurement::builder("name", 50).add_metadata("key", "value"); + /// ``` + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + /// Add measurement unit to a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Measurement::builder("name", 50000).unit("RPM"); + /// ``` + pub fn unit(mut self, unit: &str) -> MeasurementBuilder { + self.unit = Some(unit.to_string()); + self + } + + /// Builds a [`Measurement`] object from a [`MeasurementBuilder`]. + /// + /// # Examples + /// + /// ``` + /// # use ocptv::output::*; + /// let builder = Measurement::builder("name", 50); + /// let measurement = builder.build(); + /// ``` + pub fn build(self) -> Measurement { + Measurement { + name: self.name, + value: self.value, + unit: self.unit, + validators: self.validators, + hardware_info: self.hardware_info, + subcomponent: self.subcomponent, + metadata: self.metadata, + } + } +} + +/// TODO: docs +pub struct MeasurementSeriesDetail { + // note: this object is crate public and we need access to this field + // when making a new series in `StartedTestStep.add_measurement_series*` + pub(crate) id: tv::Ident, + name: String, + + unit: Option, + validators: Vec, + + hardware_info: Option, + subcomponent: Option, + + metadata: BTreeMap, +} + +impl MeasurementSeriesDetail { + pub fn new(name: &str) -> MeasurementSeriesDetail { + MeasurementSeriesDetailBuilder::new(name).build() + } + + pub fn builder(name: &str) -> MeasurementSeriesDetailBuilder { + MeasurementSeriesDetailBuilder::new(name) + } +} + +/// TODO: docs +#[derive(Default)] +pub struct MeasurementSeriesDetailBuilder { + id: tv::Ident, + name: String, + + unit: Option, + validators: Vec, + + hardware_info: Option, + subcomponent: Option, + + metadata: BTreeMap, +} + +impl MeasurementSeriesDetailBuilder { + fn new(name: &str) -> Self { + MeasurementSeriesDetailBuilder { + id: Ident::Auto, + name: name.to_string(), + ..Default::default() + } + } + + pub fn id(mut self, id: tv::Ident) -> Self { + self.id = id; + self + } + + pub fn unit(mut self, unit: &str) -> Self { + self.unit = Some(unit.to_string()); + self + } + + pub fn add_validator(mut self, validator: Validator) -> Self { + self.validators.push(validator); + self + } + + pub fn hardware_info(mut self, hardware_info: &dut::DutHardwareInfo) -> Self { + self.hardware_info = Some(hardware_info.clone()); + self + } + + pub fn subcomponent(mut self, subcomponent: dut::Subcomponent) -> Self { + self.subcomponent = Some(subcomponent); + self + } + + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + pub fn build(self) -> MeasurementSeriesDetail { + MeasurementSeriesDetail { + id: self.id, + name: self.name, + unit: self.unit, + validators: self.validators, + hardware_info: self.hardware_info, + subcomponent: self.subcomponent, + metadata: self.metadata, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output as tv; + use crate::spec; + use maplit::{btreemap, convert_args}; + use tv::dut::*; + use tv::ValidatorType; + + use anyhow::{bail, Result}; + + #[test] + fn test_measurement_as_test_step_descendant_to_artifact() -> Result<()> { + let name = "name".to_owned(); + let value = tv::Value::from(50); + let measurement = Measurement::new(&name, value.clone()); + + let artifact = measurement.to_artifact(); + assert_eq!( + artifact, + spec::Measurement { + name: name.to_string(), + unit: None, + value, + validators: None, + hardware_info: None, + subcomponent: None, + metadata: None, + } + ); + + Ok(()) + } + + #[test] + fn test_measurement_builder_as_test_step_descendant_to_artifact() -> Result<()> { + let mut dut = DutInfo::new("dut0"); + + let name = "name".to_owned(); + let value = tv::Value::from(50000); + let hw_info = dut.add_hardware_info(HardwareInfo::builder("name").build()); + let validator = Validator::builder(spec::ValidatorType::Equal, 30).build(); + + let meta_key = "key"; + let meta_value = tv::Value::from("value"); + let metadata = convert_args!(btreemap!( + meta_key => meta_value.clone(), + )); + + let subcomponent = Subcomponent::builder("name").build(); + + let unit = "RPM"; + let measurement = Measurement::builder(&name, value.clone()) + .unit(unit) + .add_validator(validator.clone()) + .add_validator(validator.clone()) + .hardware_info(&hw_info) + .subcomponent(subcomponent.clone()) + .add_metadata(meta_key, meta_value.clone()) + .build(); + + let artifact = measurement.to_artifact(); + assert_eq!( + artifact, + spec::Measurement { + name, + value, + unit: Some(unit.to_string()), + validators: Some(vec![validator.to_spec(), validator.to_spec()]), + hardware_info: Some(hw_info.to_spec()), + subcomponent: Some(subcomponent.to_spec()), + metadata: Some(metadata), + } + ); + + Ok(()) + } + + #[test] + fn test_validator() -> Result<()> { + let validator = Validator::builder(ValidatorType::Equal, 30) + .name("validator") + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .build(); + + let spec_validator = validator.to_spec(); + + assert_eq!(spec_validator.name, Some("validator".to_owned())); + assert_eq!(spec_validator.value, 30); + assert_eq!(spec_validator.validator_type, ValidatorType::Equal); + + match spec_validator.metadata { + Some(m) => { + assert_eq!(m["key"], "value"); + assert_eq!(m["key2"], "value2"); + } + _ => bail!("metadata is none"), + } + + Ok(()) + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..1a76e1d --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,61 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. +#![deny(warnings)] + +mod config; +mod diagnosis; +mod dut; +mod emitter; +mod error; +mod file; +mod log; +mod macros; +mod measure; +mod run; +mod step; +mod trait_ext; +mod writer; + +pub use crate::spec::{ + DiagnosisType, LogSeverity, SoftwareType, SubcomponentType, TestResult, TestStatus, + ValidatorType, SPEC_VERSION, +}; +pub use config::{Config, ConfigBuilder, TimestampProvider}; +pub use diagnosis::{Diagnosis, DiagnosisBuilder}; +pub use dut::{ + DutHardwareInfo, DutInfo, DutInfoBuilder, DutSoftwareInfo, HardwareInfo, HardwareInfoBuilder, + Ident, PlatformInfo, PlatformInfoBuilder, SoftwareInfo, SoftwareInfoBuilder, Subcomponent, + SubcomponentBuilder, +}; +pub use error::{Error, ErrorBuilder}; +pub use file::{File, FileBuilder}; +pub use log::{Log, LogBuilder}; +pub use measure::{ + Measurement, MeasurementBuilder, MeasurementElementDetail, MeasurementElementDetailBuilder, + MeasurementSeries, MeasurementSeriesDetail, MeasurementSeriesDetailBuilder, + StartedMeasurementSeries, Validator, ValidatorBuilder, +}; +pub use run::{ScopedTestRun, StartedTestRun, TestRun, TestRunBuilder, TestRunOutcome}; +pub use step::{ScopedTestStep, StartedTestStep, TestStep}; +pub use writer::{BufferWriter, FileWriter, StdoutWriter, Writer}; + +// re-export these as a public types we present +pub use serde_json::Value; +pub use url::Url as Uri; + +// TODO: docs +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum OcptvError { + #[error("failed to write to output stream")] + IoError(#[from] std::io::Error), + + #[error("failed to format input object")] + Format(Box), // opaque type so we don't leak impl + + #[error("other error")] + Other(Box), +} diff --git a/src/output/run.rs b/src/output/run.rs new file mode 100644 index 0000000..596be44 --- /dev/null +++ b/src/output/run.rs @@ -0,0 +1,539 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::collections::BTreeMap; +use std::env; +use std::future::Future; +use std::sync::{ + atomic::{self, Ordering}, + Arc, +}; + +use delegate::delegate; + +use crate::output as tv; +use crate::spec; +use tv::step::TestStep; +use tv::{config, dut, emitter, error, log}; + +use super::trait_ext::MapExt; + +/// The outcome of a TestRun. +/// It's returned when the scope method of the [`TestRun`] object is used. +pub struct TestRunOutcome { + /// Reports the execution status of the test + pub status: spec::TestStatus, + /// Reports the result of the test + pub result: spec::TestResult, +} + +/// The main diag test run. +/// +/// This object describes a single run instance of the diag, and therefore drives the test session. +pub struct TestRun { + name: String, + version: String, + parameters: BTreeMap, + command_line: String, + metadata: BTreeMap, + + emitter: Arc, +} + +impl TestRun { + /// Creates a new [`TestRun`] object. + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// let run = TestRun::new("diagnostic_name", "1.0"); + /// ``` + pub fn new(name: &str, version: &str) -> TestRun { + TestRunBuilder::new(name, version).build() + } + + /// Creates a new [`TestRunBuilder`] object. + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// let builder = TestRun::builder("run_name", "1.0"); + /// ``` + pub fn builder(name: &str, version: &str) -> TestRunBuilder { + TestRunBuilder::new(name, version) + } + + /// Starts the test run. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let run = TestRun::new("diagnostic_name", "1.0"); + /// let dut = DutInfo::builder("my_dut").build(); + /// run.start(dut).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn start(self, dut: dut::DutInfo) -> Result { + let start = spec::RootImpl::TestRunArtifact(spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::TestRunStart(spec::TestRunStart { + name: self.name.clone(), + version: self.version.clone(), + command_line: self.command_line.clone(), + parameters: self.parameters.clone(), + metadata: self.metadata.option(), + dut_info: dut.to_spec(), + }), + }); + + self.emitter.emit(&start).await?; + + Ok(StartedTestRun::new(self)) + } + + /// Builds a scope in the [`TestRun`] object, taking care of starting and + /// ending it. View [`TestRun::start`] and [`StartedTestRun::end`] methods. + /// After the scope is constructed, additional objects may be added to it. + /// This is the preferred usage for the [`TestRun`], since it guarantees + /// all the messages are emitted between the start and end messages, the order + /// is respected and no messages is lost. + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use futures::FutureExt; + /// # use ocptv::output::*; + /// let run = TestRun::new("diagnostic_name", "1.0"); + /// let dut = DutInfo::builder("my_dut").build(); + /// run.scope(dut, |r| { + /// async move { + /// r.add_log(LogSeverity::Info, "First message").await?; + /// Ok(TestRunOutcome { + /// status: TestStatus::Complete, + /// result: TestResult::Pass, + /// }) + /// }.boxed() + /// }).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn scope(self, dut: dut::DutInfo, func: F) -> Result<(), tv::OcptvError> + where + R: Future> + Send + 'static, + F: FnOnce(ScopedTestRun) -> R, + { + let run = Arc::new(self.start(dut).await?); + let outcome = func(ScopedTestRun { + run: Arc::clone(&run), + }) + .await?; + run.end_impl(outcome.status, outcome.result).await?; + + Ok(()) + } + + /// Emits a Error message. + /// + /// This operation is useful in such cases when there is an error before starting the test. + /// (eg. failing to discover a DUT). + /// + /// See: [`StartedTestRun::add_error`] for details and examples. + pub async fn add_error(&self, symptom: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).build(); + + self.add_error_detail(error).await?; + Ok(()) + } + + /// Emits a Error message. + /// + /// This operation is useful in such cases when there is an error before starting the test. + /// (eg. failing to discover a DUT). + /// + /// See: [`StartedTestRun::add_error_msg`] for details and examples. + pub async fn add_error_msg(&self, symptom: &str, msg: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).message(msg).build(); + + self.add_error_detail(error).await?; + Ok(()) + } + + /// Emits a Error message. + /// + /// This operation is useful in such cases when there is an error before starting the test. + /// (eg. failing to discover a DUT). + /// + /// See: [`StartedTestRun::add_error_detail`] for details and examples. + pub async fn add_error_detail(&self, error: error::Error) -> Result<(), tv::OcptvError> { + let artifact = spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::Error(error.to_artifact()), + }; + self.emitter + .emit(&spec::RootImpl::TestRunArtifact(artifact)) + .await?; + + Ok(()) + } +} + +/// Builder for the [`TestRun`] object. +#[derive(Default)] +pub struct TestRunBuilder { + name: String, + version: String, + parameters: BTreeMap, + command_line: String, + + config: Option, + metadata: BTreeMap, +} + +impl TestRunBuilder { + fn new(name: &str, version: &str) -> Self { + Self { + name: name.to_string(), + version: version.to_string(), + parameters: BTreeMap::new(), + command_line: env::args().collect::>()[1..].join(" "), + ..Default::default() + } + } + + /// Adds a user defined parameter to the future [`TestRun`] object. + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// let run = TestRun::builder("run_name", "1.0") + /// .add_parameter("param1", "value1") + /// .build(); + /// ``` + pub fn add_parameter>(mut self, key: &str, value: V) -> Self { + self.parameters.insert(key.to_string(), value.into()); + self + } + + /// Adds the command line used to run the test session to the future + /// [`TestRun`] object. + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// let run = TestRun::builder("run_name", "1.0") + /// .command_line("my_diag --arg value") + /// .build(); + /// ``` + pub fn command_line(mut self, cmd: &str) -> Self { + self.command_line = cmd.to_string(); + self + } + + /// Adds the configuration for the test session to the future [`TestRun`] object + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// let run = TestRun::builder("run_name", "1.0") + /// .config(Config::builder().build()) + /// .build(); + /// ``` + pub fn config(mut self, value: config::Config) -> Self { + self.config = Some(value); + self + } + + /// Adds user defined metadata to the future [`TestRun`] object + /// + /// # Examples + /// + /// ```rust + /// # use ocptv::output::*; + /// + /// let run = TestRun::builder("run_name", "1.0") + /// .add_metadata("meta1", "value1") + /// .build(); + /// ``` + pub fn add_metadata>(mut self, key: &str, value: V) -> Self { + self.metadata.insert(key.to_string(), value.into()); + self + } + + pub fn build(self) -> TestRun { + let config = self.config.unwrap_or(config::Config::builder().build()); + let emitter = emitter::JsonEmitter::new(config.timestamp_provider, config.writer); + + TestRun { + name: self.name, + version: self.version, + parameters: self.parameters, + command_line: self.command_line, + metadata: self.metadata, + + emitter: Arc::new(emitter), + } + } +} + +/// A test run that was started. +/// +/// ref: +pub struct StartedTestRun { + run: TestRun, + + step_seqno: atomic::AtomicU64, +} + +impl StartedTestRun { + fn new(run: TestRun) -> StartedTestRun { + StartedTestRun { + run, + step_seqno: atomic::AtomicU64::new(0), + } + } + + // note: keep the self-consuming method for crate api, but use this one internally, + // since `StartedTestRun::end` only needs to take ownership for syntactic reasons + async fn end_impl( + &self, + status: spec::TestStatus, + result: spec::TestResult, + ) -> Result<(), tv::OcptvError> { + let end = spec::RootImpl::TestRunArtifact(spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::TestRunEnd(spec::TestRunEnd { status, result }), + }); + + self.run.emitter.emit(&end).await?; + Ok(()) + } + + /// Ends the test run. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::builder("my_dut").build(); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn end( + self, + status: spec::TestStatus, + result: spec::TestResult, + ) -> Result<(), tv::OcptvError> { + self.end_impl(status, result).await + } + + /// Emits a Log message. + /// This method accepts a [`tv::LogSeverity`] to define the severity + /// and a [`String`] for the message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::builder("my_dut").build(); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// run.add_log( + /// LogSeverity::Info, + /// "This is a log message with INFO severity", + /// ).await?; + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_log( + &self, + severity: spec::LogSeverity, + msg: &str, + ) -> Result<(), tv::OcptvError> { + let log = log::Log::builder(msg).severity(severity).build(); + + let artifact = spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::Log(log.to_artifact()), + }; + self.run + .emitter + .emit(&spec::RootImpl::TestRunArtifact(artifact)) + .await?; + + Ok(()) + } + + /// Emits a Log message. + /// This method accepts a [`tv::Log`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::builder("my_dut").build(); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// run.add_log_detail( + /// Log::builder("This is a log message with INFO severity") + /// .severity(LogSeverity::Info) + /// .source("file", 1) + /// .build(), + /// ).await?; + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_log_detail(&self, log: log::Log) -> Result<(), tv::OcptvError> { + let artifact = spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::Log(log.to_artifact()), + }; + self.run + .emitter + .emit(&spec::RootImpl::TestRunArtifact(artifact)) + .await?; + + Ok(()) + } + + /// Emits a Error message. + /// This method accepts a [`String`] to define the symptom. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::builder("my_dut").build(); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// run.add_error("symptom").await?; + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error(&self, symptom: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).build(); + + self.add_error_detail(error).await?; + Ok(()) + } + + /// Emits a Error message. + /// This method accepts a [`String`] to define the symptom and + /// another [`String`] as error message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::builder("my_dut").build(); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// run.add_error_msg("symptom", "error messasge").await?; + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error_msg(&self, symptom: &str, msg: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).message(msg).build(); + + self.add_error_detail(error).await?; + Ok(()) + } + + /// Emits a Error message. + /// This method accepts an [`tv::Error`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("my_dut"); + /// let sw_info = dut.add_software_info(SoftwareInfo::builder("name").build()); + /// let run = TestRun::builder("diagnostic_name", "1.0").build().start(dut).await?; + /// + /// run.add_error_detail( + /// Error::builder("symptom") + /// .message("Error message") + /// .source("file", 1) + /// .add_software_info(&sw_info) + /// .build(), + /// ).await?; + /// + /// run.end(TestStatus::Complete, TestResult::Pass).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error_detail(&self, error: error::Error) -> Result<(), tv::OcptvError> { + let artifact = spec::TestRunArtifact { + artifact: spec::TestRunArtifactImpl::Error(error.to_artifact()), + }; + self.run + .emitter + .emit(&spec::RootImpl::TestRunArtifact(artifact)) + .await?; + + Ok(()) + } + + /// Create a new step for this test run. + /// TODO: docs + example + pub fn add_step(&self, name: &str) -> TestStep { + let step_id = format!("step{}", self.step_seqno.fetch_add(1, Ordering::AcqRel)); + TestStep::new(&step_id, name, Arc::clone(&self.run.emitter)) + } +} + +/// TODO: docs +pub struct ScopedTestRun { + run: Arc, +} + +impl ScopedTestRun { + delegate! { + to self.run { + pub async fn add_log(&self, severity: spec::LogSeverity, msg: &str) -> Result<(), tv::OcptvError>; + pub async fn add_log_detail(&self, log: log::Log) -> Result<(), tv::OcptvError>; + + pub async fn add_error(&self, symptom: &str) -> Result<(), tv::OcptvError>; + pub async fn add_error_msg(&self, symptom: &str, msg: &str) -> Result<(), tv::OcptvError>; + pub async fn add_error_detail(&self, error: error::Error) -> Result<(), tv::OcptvError>; + + pub fn add_step(&self, name: &str) -> TestStep; + } + } +} diff --git a/src/output/step.rs b/src/output/step.rs new file mode 100644 index 0000000..b9d4cba --- /dev/null +++ b/src/output/step.rs @@ -0,0 +1,758 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::future::Future; +use std::io; +use std::sync::atomic::{self, Ordering}; +use std::sync::Arc; + +use delegate::delegate; + +use crate::output as tv; +use crate::spec::{self, TestStepArtifactImpl}; +use tv::OcptvError; +use tv::{config, diagnosis, emitter, error, file, log, measure, Ident}; + +/// A single test step in the scope of a [`tv::TestRun`]. +/// +/// ref: +pub struct TestStep { + name: String, + + emitter: Arc, +} + +impl TestStep { + // note: this object is crate public but users should only construct + // instances through the `StartedTestRun.add_step` api + pub(crate) fn new(id: &str, name: &str, run_emitter: Arc) -> Self { + TestStep { + name: name.to_owned(), + emitter: Arc::new(StepEmitter { + step_id: id.to_owned(), + emitter: run_emitter, + }), + } + } + + /// Starts the test step. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn start(self) -> Result { + self.emitter + .emit(&TestStepArtifactImpl::TestStepStart(spec::TestStepStart { + name: self.name.clone(), + })) + .await?; + + Ok(StartedTestStep { + step: self, + measurement_seqno: Arc::new(atomic::AtomicU64::new(0)), + }) + } + + /// Builds a scope in the [`TestStep`] object, taking care of starting and + /// ending it. View [`TestStep::start`] and [`StartedTestStep::end`] methods. + /// After the scope is constructed, additional objects may be added to it. + /// This is the preferred usage for the [`TestStep`], since it guarantees + /// all the messages are emitted between the start and end messages, the order + /// is respected and no messages is lost. + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use futures::FutureExt; + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("first step"); + /// step.scope(|s| { + /// async move { + /// s.add_log( + /// LogSeverity::Info, + /// "This is a log message with INFO severity", + /// ).await?; + /// Ok(TestStatus::Complete) + /// }.boxed() + /// }).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn scope(self, func: F) -> Result<(), tv::OcptvError> + where + R: Future> + Send + 'static, + F: FnOnce(ScopedTestStep) -> R + Send + 'static, + { + let step = Arc::new(self.start().await?); + let status = func(ScopedTestStep { + step: Arc::clone(&step), + }) + .await?; + step.end_impl(status).await?; + + Ok(()) + } +} + +/// TODO: docs +pub struct StartedTestStep { + step: TestStep, + measurement_seqno: Arc, +} + +impl StartedTestStep { + // note: keep the self-consuming method for crate api, but use this one internally, + // since `StartedTestStep::end` only needs to take ownership for syntactic reasons + async fn end_impl(&self, status: tv::TestStatus) -> Result<(), tv::OcptvError> { + let end = TestStepArtifactImpl::TestStepEnd(spec::TestStepEnd { status }); + + self.step.emitter.emit(&end).await?; + Ok(()) + } + + /// Ends the test step. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn end(self, status: tv::TestStatus) -> Result<(), tv::OcptvError> { + self.end_impl(status).await + } + + /// Emits Log message. + /// This method accepts a [`tv::LogSeverity`] to define the severity + /// and a [`String`] for the message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_log( + /// LogSeverity::Info, + /// "This is a log message with INFO severity", + /// ).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + /// ## Using macros + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// use ocptv::ocptv_log_info; + /// + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// ocptv_log_info!(step, "This is a log message with INFO severity").await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_log( + &self, + severity: spec::LogSeverity, + msg: &str, + ) -> Result<(), tv::OcptvError> { + let log = log::Log::builder(msg).severity(severity).build(); + + self.step + .emitter + .emit(&TestStepArtifactImpl::Log(log.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits Log message. + /// This method accepts a [`tv::Log`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_log_detail( + /// Log::builder("This is a log message with INFO severity") + /// .severity(LogSeverity::Info) + /// .source("file", 1) + /// .build(), + /// ).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_log_detail(&self, log: log::Log) -> Result<(), tv::OcptvError> { + self.step + .emitter + .emit(&TestStepArtifactImpl::Log(log.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits an Error symptom. + /// This method accepts a [`String`] to define the symptom. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_error("symptom").await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + /// + /// ## Using macros + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// use ocptv::ocptv_error; + /// + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// ocptv_error!(step, "symptom").await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error(&self, symptom: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).build(); + + self.step + .emitter + .emit(&TestStepArtifactImpl::Error(error.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits an Error message. + /// This method accepts a [`String`] to define the symptom and + /// another [`String`] as error message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_error_msg("symptom", "error message").await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + /// + /// ## Using macros + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// use ocptv::ocptv_error; + /// + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// ocptv_error!(step, "symptom", "error message").await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error_msg(&self, symptom: &str, msg: &str) -> Result<(), tv::OcptvError> { + let error = error::Error::builder(symptom).message(msg).build(); + + self.step + .emitter + .emit(&TestStepArtifactImpl::Error(error.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits a Error message. + /// This method accepts a [`tv::Error`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("my_dut"); + /// let sw_info = dut.add_software_info(SoftwareInfo::builder("name").build()); + /// let run = TestRun::builder("diagnostic_name", "1.0").build().start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_error_detail( + /// Error::builder("symptom") + /// .message("Error message") + /// .source("file", 1) + /// .add_software_info(&sw_info) + /// .build(), + /// ).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_error_detail(&self, error: error::Error) -> Result<(), tv::OcptvError> { + self.step + .emitter + .emit(&TestStepArtifactImpl::Error(error.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits a Measurement message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_measurement("name", 50).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_measurement>( + &self, + name: &str, + value: V, + ) -> Result<(), tv::OcptvError> { + let measurement = measure::Measurement::new(name, value); + + self.step + .emitter + .emit(&TestStepArtifactImpl::Measurement( + measurement.to_artifact(), + )) + .await?; + + Ok(()) + } + + /// Emits a Measurement message. + /// This method accepts a [`tv::Error`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("my_dut"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("fan").build()); + /// let run = TestRun::builder("diagnostic_name", "1.0").build().start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let measurement = Measurement::builder("name", 5000) + /// .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) + /// .add_metadata("key", "value") + /// .hardware_info(&hw_info) + /// .subcomponent(Subcomponent::builder("name").build()) + /// .build(); + /// step.add_measurement_detail(measurement).await?; + /// + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_measurement_detail( + &self, + detail: measure::Measurement, + ) -> Result<(), tv::OcptvError> { + self.step + .emitter + .emit(&spec::TestStepArtifactImpl::Measurement( + detail.to_artifact(), + )) + .await?; + + Ok(()) + } + + /// Create a Measurement Series (a time-series list of measurements). + /// This method accepts a [`String`] as series ID and a [`String`] as series name. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// let series = step.add_measurement_series("name"); + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub fn add_measurement_series(&self, name: &str) -> tv::MeasurementSeries { + self.add_measurement_series_detail(tv::MeasurementSeriesDetail::new(name)) + } + + /// Create a Measurement Series (a time-series list of measurements). + /// This method accepts a [`tv::MeasurementSeriesDetail`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// let series = + /// step.add_measurement_series_detail(MeasurementSeriesDetail::new("name")); + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub fn add_measurement_series_detail( + &self, + detail: measure::MeasurementSeriesDetail, + ) -> tv::MeasurementSeries { + // spec says this identifier is unique in the scope of the test run, so create it from + // the step identifier and a counter + // ref: https://github.com/opencomputeproject/ocp-diag-core/blob/main/json_spec/README.md#measurementseriesstart + let series_id = match &detail.id { + Ident::Auto => format!( + "{}_series{}", + self.step.emitter.step_id, + self.measurement_seqno.fetch_add(1, Ordering::AcqRel) + ), + Ident::Exact(value) => value.to_owned(), + }; + + tv::MeasurementSeries::new(&series_id, detail, Arc::clone(&self.step.emitter)) + } + + /// Emits a Diagnosis message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// step.add_diagnosis("verdict", DiagnosisType::Pass).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_diagnosis( + &self, + verdict: &str, + diagnosis_type: spec::DiagnosisType, + ) -> Result<(), tv::OcptvError> { + let diagnosis = diagnosis::Diagnosis::new(verdict, diagnosis_type); + + self.step + .emitter + .emit(&TestStepArtifactImpl::Diagnosis(diagnosis.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits a Diagnosis message. + /// This method accepts a [`tv::Error`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let mut dut = DutInfo::new("my_dut"); + /// let hw_info = dut.add_hardware_info(HardwareInfo::builder("fan").build()); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// let diagnosis = Diagnosis::builder("verdict", DiagnosisType::Pass) + /// .hardware_info(&hw_info) + /// .message("message") + /// .subcomponent(&Subcomponent::builder("name").build()) + /// .source("file.rs", 1) + /// .build(); + /// step.add_diagnosis_detail(diagnosis).await?; + /// + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_diagnosis_detail( + &self, + diagnosis: diagnosis::Diagnosis, + ) -> Result<(), tv::OcptvError> { + self.step + .emitter + .emit(&spec::TestStepArtifactImpl::Diagnosis( + diagnosis.to_artifact(), + )) + .await?; + + Ok(()) + } + + /// Emits a File message. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// + /// let step = run.add_step("step_name").start().await?; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// step.add_file("name", uri).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_file(&self, name: &str, uri: tv::Uri) -> Result<(), tv::OcptvError> { + let file = file::File::new(name, uri); + + self.step + .emitter + .emit(&TestStepArtifactImpl::File(file.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits a File message. + /// This method accepts a [`tv::Error`] object. + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// # use std::str::FromStr; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// + /// let step = run.add_step("step_name").start().await?; + /// + /// let uri = Uri::parse("file:///tmp/foo").unwrap(); + /// let file = File::builder("name", uri) + /// .description("description") + /// .content_type(mime::TEXT_PLAIN) + /// .add_metadata("key", "value") + /// .build(); + /// step.add_file_detail(file).await?; + /// step.end(TestStatus::Complete).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_file_detail(&self, file: file::File) -> Result<(), tv::OcptvError> { + self.step + .emitter + .emit(&spec::TestStepArtifactImpl::File(file.to_artifact())) + .await?; + + Ok(()) + } + + /// Emits an extension message; + /// + /// ref: + /// + /// # Examples + /// + /// ```rust + /// # tokio_test::block_on(async { + /// # use ocptv::output::*; + /// let dut = DutInfo::new("my_dut"); + /// let run = TestRun::new("diagnostic_name", "1.0").start(dut).await?; + /// let step = run.add_step("step_name").start().await?; + /// + /// #[derive(serde::Serialize)] + /// struct Ext { i: u32 } + /// + /// step.add_extension("ext_name", Ext { i: 42 }).await?; + /// + /// # Ok::<(), OcptvError>(()) + /// # }); + /// ``` + pub async fn add_extension( + &self, + name: &str, + any: S, + ) -> Result<(), tv::OcptvError> { + let ext = TestStepArtifactImpl::Extension(spec::Extension { + name: name.to_owned(), + content: serde_json::to_value(&any).map_err(|e| OcptvError::Format(Box::new(e)))?, + }); + + self.step.emitter.emit(&ext).await?; + Ok(()) + } +} + +/// TODO: docs +pub struct ScopedTestStep { + step: Arc, +} + +impl ScopedTestStep { + delegate! { + to self.step { + pub async fn add_log(&self, severity: spec::LogSeverity, msg: &str) -> Result<(), tv::OcptvError>; + pub async fn add_log_detail(&self, log: log::Log) -> Result<(), tv::OcptvError>; + + pub async fn add_error(&self, symptom: &str) -> Result<(), tv::OcptvError>; + pub async fn add_error_msg(&self, symptom: &str, msg: &str) -> Result<(), tv::OcptvError>; + pub async fn add_error_detail(&self, error: error::Error) -> Result<(), tv::OcptvError>; + + pub async fn add_measurement>(&self, name: &str, value: V) -> Result<(), tv::OcptvError>; + pub async fn add_measurement_detail(&self, detail: measure::Measurement) -> Result<(), tv::OcptvError>; + + pub fn add_measurement_series(&self, name: &str) -> tv::MeasurementSeries; + pub fn add_measurement_series_detail( + &self, + detail: measure::MeasurementSeriesDetail, + ) -> tv::MeasurementSeries; + + pub async fn add_diagnosis( + &self, + verdict: &str, + diagnosis_type: spec::DiagnosisType, + ) -> Result<(), tv::OcptvError>; + pub async fn add_diagnosis_detail(&self, diagnosis: diagnosis::Diagnosis) -> Result<(), tv::OcptvError>; + + pub async fn add_file(&self, name: &str, uri: tv::Uri) -> Result<(), tv::OcptvError>; + pub async fn add_file_detail(&self, file: file::File) -> Result<(), tv::OcptvError>; + + pub async fn add_extension(&self, name: &str, any: S) -> Result<(), tv::OcptvError>; + } + } +} + +pub struct StepEmitter { + step_id: String, + // root emitter + emitter: Arc, +} + +impl StepEmitter { + pub async fn emit(&self, object: &spec::TestStepArtifactImpl) -> Result<(), io::Error> { + let root = spec::RootImpl::TestStepArtifact(spec::TestStepArtifact { + id: self.step_id.clone(), + // TODO: can these copies be avoided? + artifact: object.clone(), + }); + self.emitter.emit(&root).await?; + + Ok(()) + } + + pub fn timestamp_provider(&self) -> &(dyn config::TimestampProvider + Send + Sync + 'static) { + self.emitter.timestamp_provider() + } +} diff --git a/src/output/trait_ext.rs b/src/output/trait_ext.rs new file mode 100644 index 0000000..ce4e69e --- /dev/null +++ b/src/output/trait_ext.rs @@ -0,0 +1,32 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::collections::BTreeMap; + +pub trait VecExt { + fn map_option(&self, func: F) -> Option> + where + F: Fn(&T) -> U; +} + +impl VecExt for Vec { + fn map_option(&self, func: F) -> Option> + where + F: Fn(&T) -> U, + { + (!self.is_empty()).then_some(self.iter().map(func).collect()) + } +} + +pub trait MapExt { + fn option(&self) -> Option>; +} + +impl MapExt for BTreeMap { + fn option(&self) -> Option> { + (!self.is_empty()).then_some(self.clone()) + } +} diff --git a/src/output/writer.rs b/src/output/writer.rs new file mode 100644 index 0000000..0905075 --- /dev/null +++ b/src/output/writer.rs @@ -0,0 +1,127 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::convert::Infallible; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +/// TODO: docs +#[async_trait] +pub trait Writer { + async fn write(&self, s: &str) -> Result<(), io::Error>; +} + +pub enum WriterType { + // optimization: static dispatch for these known types + Stdout(StdoutWriter), + File(FileWriter), + Buffer(BufferWriter), + + Custom(Box), +} + +/// TODO: docs +pub struct FileWriter { + file: Arc>, +} + +impl FileWriter { + pub async fn new>(path: P) -> Result { + let file = fs::File::create(path).await?; + Ok(FileWriter { + file: Arc::new(Mutex::new(file)), + }) + } + + pub async fn write(&self, s: &str) -> Result<(), io::Error> { + let mut handle = self.file.lock().await; + + let mut buf = Vec::::new(); + writeln!(buf, "{}", s)?; + + handle.write_all(&buf).await?; + handle.flush().await?; + + Ok(()) + } +} + +/// TODO: docs +#[derive(Debug)] +pub struct BufferWriter { + buffer: Arc>>, +} + +impl BufferWriter { + pub fn new(buffer: Arc>>) -> Self { + Self { buffer } + } + + pub async fn write(&self, s: &str) -> Result<(), Infallible> { + self.buffer.lock().await.push(s.to_string()); + Ok(()) + } +} + +/// TODO: docs +#[derive(Debug, Clone)] +pub struct StdoutWriter {} + +#[allow(clippy::new_without_default)] +impl StdoutWriter { + pub fn new() -> Self { + StdoutWriter {} + } + + pub async fn write(&self, s: &str) -> Result<(), Infallible> { + println!("{}", s); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::*; + use anyhow::Result; + + struct ErrorWriter {} + + #[async_trait] + impl Writer for ErrorWriter { + async fn write(&self, _s: &str) -> Result<(), io::Error> { + Err(io::Error::other("err")) + } + } + + #[tokio::test] + async fn test_ocptv_error_has_public_source() -> Result<()> { + let dut = DutInfo::builder("dut_id").build(); + let run_builder = TestRun::builder("run_name", "1.0").config( + Config::builder() + .with_custom_output(Box::new(ErrorWriter {})) + .build(), + ); + + let actual = run_builder.build().start(dut).await; + assert!(actual.is_err()); + + match &actual { + Err(OcptvError::IoError(ioe)) => { + assert_eq!(ioe.kind(), io::ErrorKind::Other); + } + _ => panic!("unknown error"), + } + + Ok(()) + } +} diff --git a/src/spec.rs b/src/spec.rs new file mode 100644 index 0000000..f61e9dc --- /dev/null +++ b/src/spec.rs @@ -0,0 +1,960 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// + +use std::collections::BTreeMap; + +use chrono::DateTime; +use serde::Deserialize; +use serde::Serialize; +use serde_with::serde_as; + +use crate::output as tv; + +/// TODO: docs +pub const SPEC_VERSION: (i8, i8) = (2, 0); + +mod rfc3339_format { + use chrono::DateTime; + use chrono::SecondsFormat; + use serde::Deserialize; + + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: serde::Serializer, + { + let s = date.to_rfc3339_opts(SecondsFormat::Millis, true); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?; + Ok(dt.with_timezone(&chrono_tz::Tz::UTC)) + } +} + +mod serialize_ids { + pub trait IdGetter { + fn id(&self) -> &str; + } + + pub struct IdFromGetter; + + impl serde_with::SerializeAs for IdFromGetter + where + T: IdGetter, + { + fn serialize_as(source: &T, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(source.id()) + } + } +} + +/// TODO: docs +#[derive(Debug, Serialize, Clone, PartialEq)] +#[non_exhaustive] +pub enum ValidatorType { + #[serde(rename = "EQUAL")] + Equal, + #[serde(rename = "NOT_EQUAL")] + NotEqual, + #[serde(rename = "LESS_THAN")] + LessThan, + #[serde(rename = "LESS_THAN_OR_EQUAL")] + LessThanOrEqual, + #[serde(rename = "GREATER_THAN")] + GreaterThan, + #[serde(rename = "GREATER_THAN_OR_EQUAL")] + GreaterThanOrEqual, + #[serde(rename = "REGEX_MATCH")] + RegexMatch, + #[serde(rename = "REGEX_NO_MATCH")] + RegexNoMatch, + #[serde(rename = "IN_SET")] + InSet, + #[serde(rename = "NOT_IN_SET")] + NotInSet, +} + +/// TODO: docs +#[derive(Debug, Serialize, Clone, PartialEq)] +#[non_exhaustive] +pub enum SubcomponentType { + #[serde(rename = "UNSPECIFIED")] + Unspecified, + #[serde(rename = "ASIC")] + Asic, + #[serde(rename = "ASIC-SUBSYSTEM")] + AsicSubsystem, + #[serde(rename = "BUS")] + Bus, + #[serde(rename = "FUNCTION")] + Function, + #[serde(rename = "CONNECTOR")] + Connector, +} + +/// Outcome of a diagnosis operation. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone, Default)] +#[non_exhaustive] +pub enum DiagnosisType { + #[serde(rename = "PASS")] + #[default] + Pass, + #[serde(rename = "FAIL")] + Fail, + #[serde(rename = "UNKNOWN")] + Unknown, +} + +/// Represents the final execution status of a test. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "testStatus")] +#[non_exhaustive] +pub enum TestStatus { + #[serde(rename = "COMPLETE")] + Complete, + #[serde(rename = "ERROR")] + Error, + #[serde(rename = "SKIP")] + Skip, +} + +/// Represents the final outcome of a test execution. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "testResult")] +#[non_exhaustive] +pub enum TestResult { + #[serde(rename = "PASS")] + Pass, + #[serde(rename = "FAIL")] + Fail, + #[serde(rename = "NOT_APPLICABLE")] + NotApplicable, +} + +/// Known log severity variants. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[non_exhaustive] +pub enum LogSeverity { + #[serde(rename = "DEBUG")] + Debug, + #[serde(rename = "INFO")] + Info, + #[serde(rename = "WARNING")] + Warning, + #[serde(rename = "ERROR")] + Error, + #[serde(rename = "FATAL")] + Fatal, +} + +/// Type specification for a software component of the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "softwareType")] +#[non_exhaustive] +pub enum SoftwareType { + #[serde(rename = "UNSPECIFIED")] + Unspecified, + #[serde(rename = "FIRMWARE")] + Firmware, + #[serde(rename = "SYSTEM")] + System, + #[serde(rename = "APPLICATION")] + Application, +} + +#[derive(Debug, Serialize, Clone)] +pub struct Root { + #[serde(flatten)] + pub artifact: RootImpl, + + // TODO : manage different timezones + #[serde(rename = "timestamp")] + #[serde(with = "rfc3339_format")] + pub timestamp: DateTime, + + #[serde(rename = "sequenceNumber")] + pub seqno: u64, +} + +#[derive(Debug, Serialize, PartialEq, Clone)] +#[non_exhaustive] +pub enum RootImpl { + #[serde(rename = "schemaVersion")] + SchemaVersion(SchemaVersion), + + #[serde(rename = "testRunArtifact")] + TestRunArtifact(TestRunArtifact), + + #[serde(rename = "testStepArtifact")] + TestStepArtifact(TestStepArtifact), +} + +/// Low-level model for the `schemaVersion` spec object. +/// Specifies the version that should be used to interpret following json outputs. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "schemaVersion")] +pub struct SchemaVersion { + #[serde(rename = "major")] + pub major: i8, + + #[serde(rename = "minor")] + pub minor: i8, +} + +impl Default for SchemaVersion { + fn default() -> Self { + SchemaVersion { + major: SPEC_VERSION.0, + minor: SPEC_VERSION.1, + } + } +} + +/// Low-level model for the `testRunArtifact` spec object. +/// Container for the run level artifacts. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +pub struct TestRunArtifact { + #[serde(flatten)] + pub artifact: TestRunArtifactImpl, +} + +#[derive(Debug, Serialize, PartialEq, Clone)] +#[non_exhaustive] +pub enum TestRunArtifactImpl { + #[serde(rename = "testRunStart")] + TestRunStart(TestRunStart), + + #[serde(rename = "testRunEnd")] + TestRunEnd(TestRunEnd), + + #[serde(rename = "log")] + Log(Log), + + #[serde(rename = "error")] + Error(Error), +} + +/// Low-level model for the `testRunStart` spec object. +/// Start marker for the beginning of a diagnostic test. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "testRunStart")] +pub struct TestRunStart { + #[serde(rename = "name")] + pub name: String, + + #[serde(rename = "version")] + pub version: String, + + #[serde(rename = "commandLine")] + pub command_line: String, + + #[serde(rename = "parameters")] + pub parameters: BTreeMap, + + #[serde(rename = "dutInfo")] + pub dut_info: DutInfo, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `dutInfo` spec object. +/// Contains all relevant information describing the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename = "dutInfo")] +pub struct DutInfo { + #[serde(rename = "dutInfoId")] + pub id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "name")] + pub name: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "platformInfos")] + pub platform_infos: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "softwareInfos")] + pub software_infos: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "hardwareInfos")] + pub hardware_infos: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `platformInfo` spec object. +/// Describe platform specific attributes of the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename = "platformInfo")] +pub struct PlatformInfo { + #[serde(rename = "info")] + pub info: String, +} + +/// Low-level model for the `softwareInfo` spec object. +/// Represents information of a discovered or exercised software component of the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "softwareInfo")] +pub struct SoftwareInfo { + #[serde(rename = "softwareInfoId")] + pub id: String, + + #[serde(rename = "name")] + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "version")] + pub version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "revision")] + pub revision: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "softwareType")] + pub software_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "computerSystem")] + pub computer_system: Option, +} + +impl serialize_ids::IdGetter for SoftwareInfo { + fn id(&self) -> &str { + &self.id + } +} + +/// Low-level model for the `hardwareInfo` spec object. +/// Represents information of an enumerated or exercised hardware component of the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename = "hardwareInfo")] +pub struct HardwareInfo { + #[serde(rename = "hardwareInfoId")] + pub id: String, + + #[serde(rename = "name")] + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "version")] + pub version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "revision")] + pub revision: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "location")] + pub location: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "serialNumber")] + pub serial_no: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "partNumber")] + pub part_no: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "manufacturer")] + pub manufacturer: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "manufacturerPartNumber")] + pub manufacturer_part_no: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "odataId")] + pub odata_id: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "computerSystem")] + pub computer_system: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "manager")] + pub manager: Option, +} + +impl serialize_ids::IdGetter for HardwareInfo { + fn id(&self) -> &str { + &self.id + } +} + +/// Low-level model for the `testRunEnd` spec object. +/// End marker signaling the finality of a diagnostic test. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "testRunEnd")] +pub struct TestRunEnd { + #[serde(rename = "status")] + pub status: TestStatus, + + #[serde(rename = "result")] + pub result: TestResult, +} + +/// Low-level model for the `error` spec object. +/// Represents an error encountered by the diagnostic software. It may refer to a DUT +/// component or the diagnostic itself. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[serde_as] +#[derive(Debug, Serialize, Default, Clone, PartialEq)] +#[serde(rename = "error")] +pub struct Error { + #[serde(rename = "symptom")] + pub symptom: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "message")] + pub message: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "softwareInfoIds")] + #[serde_as(as = "Option>")] + pub software_infos: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sourceLocation")] + pub source_location: Option, +} + +/// Low-level model for `log` spec object. +/// Is currently relevant for test run and test step artifact types. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "log")] +pub struct Log { + #[serde(rename = "severity")] + pub severity: LogSeverity, + + #[serde(rename = "message")] + pub message: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sourceLocation")] + pub source_location: Option, +} + +/// Provides information about which file/line of the source code in +/// the diagnostic package generated the output. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, Default, PartialEq)] +#[serde(rename = "sourceLocation")] +pub struct SourceLocation { + #[serde(rename = "file")] + pub file: String, + + #[serde(rename = "line")] + pub line: i32, +} + +/// Low-level model for the `testStepArtifact` spec object. +/// Container for the step level artifacts. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +pub struct TestStepArtifact { + #[serde(rename = "testStepId")] + pub id: String, + + #[serde(flatten)] + pub artifact: TestStepArtifactImpl, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Serialize, PartialEq, Clone)] +#[non_exhaustive] +pub enum TestStepArtifactImpl { + #[serde(rename = "testStepStart")] + TestStepStart(TestStepStart), + + #[serde(rename = "testStepEnd")] + TestStepEnd(TestStepEnd), + + #[serde(rename = "measurement")] + Measurement(Measurement), + + #[serde(rename = "measurementSeriesStart")] + MeasurementSeriesStart(MeasurementSeriesStart), + + #[serde(rename = "measurementSeriesEnd")] + MeasurementSeriesEnd(MeasurementSeriesEnd), + + #[serde(rename = "measurementSeriesElement")] + MeasurementSeriesElement(MeasurementSeriesElement), + + #[serde(rename = "diagnosis")] + Diagnosis(Diagnosis), + + #[serde(rename = "log")] + Log(Log), + + #[serde(rename = "error")] + Error(Error), + + #[serde(rename = "file")] + File(File), + + #[serde(rename = "extension")] + Extension(Extension), +} + +/// Low-level model for the `testStepStart` spec object. +/// Start marker for a test step inside a diagnosis run. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "testStepStart")] +pub struct TestStepStart { + #[serde(rename = "name")] + pub name: String, +} + +/// Low-level model for the `testStepEnd` spec object. +/// End marker for a test step inside a diagnosis run. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "testStepEnd")] +pub struct TestStepEnd { + #[serde(rename = "status")] + pub status: TestStatus, +} + +/// Low-level model for the `measurement` spec object. +/// Represents an individual measurement taken by the diagnostic regarding the DUT. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[serde_as] +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "measurement")] +pub struct Measurement { + #[serde(rename = "name")] + pub name: String, + + #[serde(rename = "value")] + pub value: tv::Value, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "unit")] + pub unit: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "validators")] + pub validators: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "hardwareInfoId")] + #[serde_as(as = "Option")] + pub hardware_info: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "subcomponent")] + pub subcomponent: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `validator` spec object. +/// Contains the validation logic that the diagnostic applied for a specific measurement. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "validator")] +pub struct Validator { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "name")] + pub name: Option, + + #[serde(rename = "type")] + pub validator_type: ValidatorType, + + #[serde(rename = "value")] + pub value: tv::Value, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `subcomponent` spec object. +/// Represents a physical subcomponent of a DUT hardware element. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename = "subcomponent")] +pub struct Subcomponent { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "type")] + pub subcomponent_type: Option, + + #[serde(rename = "name")] + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "location")] + pub location: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "version")] + pub version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "revision")] + pub revision: Option, +} + +/// Low-level model for the `measurementSeriesStart` spec object. +/// Start marker for a time based series of measurements. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[serde_as] +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "measurementSeriesStart")] +pub struct MeasurementSeriesStart { + #[serde(rename = "name")] + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "unit")] + pub unit: Option, + + #[serde(rename = "measurementSeriesId")] + pub series_id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "validators")] + pub validators: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "hardwareInfoId")] + #[serde_as(as = "Option")] + pub hardware_info: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "subcomponent")] + pub subcomponent: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `measurementSeriesEnd` spec object. +/// End marker for a time based series of measurements. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "measurementSeriesEnd")] +pub struct MeasurementSeriesEnd { + #[serde(rename = "measurementSeriesId")] + pub series_id: String, + + #[serde(rename = "totalCount")] + pub total_count: u64, +} + +/// Low-level model for the `measurementSeriesElement` spec object. +/// Equivalent to the `Measurement` model but inside a time based series. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename = "measurementSeriesElement")] +pub struct MeasurementSeriesElement { + #[serde(rename = "index")] + pub index: u64, + + #[serde(rename = "value")] + pub value: tv::Value, + + #[serde(with = "rfc3339_format")] + pub timestamp: DateTime, + + #[serde(rename = "measurementSeriesId")] + pub series_id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `diagnosis` spec object. +/// Contains the verdict given by the diagnostic regarding the DUT that was inspected. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[serde_as] +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "diagnosis")] +pub struct Diagnosis { + #[serde(rename = "verdict")] + pub verdict: String, + + #[serde(rename = "type")] + pub diagnosis_type: DiagnosisType, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "message")] + pub message: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "hardwareInfoId")] + #[serde_as(as = "Option")] + pub hardware_info: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "subcomponent")] + pub subcomponent: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sourceLocation")] + pub source_location: Option, +} + +/// Low-level model for the `file` spec object. +/// Represents a file artifact that was generated by running the diagnostic. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "file")] +pub struct File { + #[serde(rename = "displayName")] + pub name: String, + + #[serde(rename = "uri")] + pub uri: String, + + #[serde(rename = "isSnapshot")] + pub is_snapshot: bool, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "description")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "contentType")] + pub content_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "metadata")] + pub metadata: Option>, +} + +/// Low-level model for the `extension` spec object. +/// Left as an implementation detail, the `Extension` just has a name and arbitrary data. +/// +/// ref: +/// +/// schema url: +/// +/// schema ref: +#[derive(Debug, Serialize, PartialEq, Clone)] +#[serde(rename = "extension")] +pub struct Extension { + #[serde(rename = "name")] + pub name: String, + + // note: have to use a json specific here; alternative is to propagate up an E: Serialize, + // which polutes all of the types. Trait Serialize is also not object safe. + #[serde(rename = "content")] + pub content: serde_json::Value, +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use assert_json_diff::assert_json_include; + use chrono::SecondsFormat; + use serde_json::json; + + use super::*; + + #[test] + fn test_rfc3339_format_serialize() -> Result<()> { + let test_date = "2022-01-01T00:00:00.000Z"; + let msr = MeasurementSeriesElement { + index: 0, + value: 1.0.into(), + timestamp: DateTime::parse_from_rfc3339(test_date)?.with_timezone(&chrono_tz::UTC), + series_id: "test".to_string(), + metadata: None, + }; + let json = serde_json::to_value(msr)?; + assert_json_include!(actual: json, expected: json!({ + "timestamp": test_date, + })); + + Ok(()) + } + + #[test] + fn test_rfc3339_format_deserialize() -> Result<()> { + let test_date = "2022-01-01T00:00:00.000Z"; + let json = json!({"index":0,"measurementSeriesId":"test","metadata":null,"timestamp":"2022-01-01T00:00:00.000Z","value":1.0}); + + let msr = serde_json::from_value::(json)?; + assert_eq!( + msr.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true), + test_date + ); + + Ok(()) + } +} diff --git a/tests/output/config.rs b/tests/output/config.rs new file mode 100644 index 0000000..81f0c57 --- /dev/null +++ b/tests/output/config.rs @@ -0,0 +1,88 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +#[cfg(coverage)] +use anyhow::Result; + +// reasoning: the coverage(off) attribute is experimental in llvm-cov, so because we cannot +// disable the coverage itself, only run this test when in coverage mode because assert_fs +// does ultimately assume there's a real filesystem somewhere +#[cfg(coverage)] +#[tokio::test] +async fn test_config_builder_with_file() -> Result<()> { + use std::fs; + + use assert_fs::prelude::*; + use assert_json_diff::assert_json_include; + use predicates::prelude::*; + use serde_json::json; + + use ocptv::output::{Config, DutInfo, TestResult, TestRun, TestStatus}; + + use super::fixture::*; + + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "testRunStart": { + "dutInfo": { + "dutInfoId": "dut_id" + }, + "name": "run_name", + "parameters": {}, + "version": "1.0", + "commandLine": "" + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testRunArtifact": { + "error": { + "message": "Error message", + "symptom": "symptom" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + let fs = assert_fs::TempDir::new()?; + let output_file = fs.child("output.jsonl"); + + let dut = DutInfo::builder("dut_id").build(); + + let run = TestRun::builder("run_name", "1.0") + .config( + Config::builder() + .timezone(chrono_tz::Europe::Rome) + .with_timestamp_provider(Box::new(FixedTsProvider {})) + .with_file_output(output_file.path()) + .await? + .build(), + ) + .build() + .start(dut) + .await?; + + run.add_error_msg("symptom", "Error message").await?; + + run.end(TestStatus::Complete, TestResult::Pass).await?; + + output_file.assert(predicate::path::exists()); + let content = fs::read_to_string(output_file.path())?; + + for (idx, entry) in content.lines().enumerate() { + let value = serde_json::from_str::(entry).unwrap(); + assert_json_include!(actual: value, expected: &expected[idx]); + } + + Ok(()) +} diff --git a/tests/output/diagnosis.rs b/tests/output/diagnosis.rs new file mode 100644 index 0000000..ecd439a --- /dev/null +++ b/tests/output/diagnosis.rs @@ -0,0 +1,82 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde_json::json; + +use ocptv::output::{Diagnosis, DiagnosisType, Subcomponent}; + +use super::fixture::*; + +#[tokio::test] +async fn test_step_with_diagnosis() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "diagnosis": { + "verdict": "verdict", + "type": "PASS" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_diagnosis("verdict", DiagnosisType::Pass).await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_diagnosis_builder() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "diagnosis": { + "verdict": "verdict", + "type": "PASS", + "message": "message", + "hardwareInfoId": "hw0", + "subcomponent": { + "name": "name" + }, + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, dut| { + async move { + let diagnosis = Diagnosis::builder("verdict", DiagnosisType::Pass) + .hardware_info(dut.hardware_info("hw0").unwrap()) // must exist + .subcomponent(&Subcomponent::builder("name").build()) + .message("message") + .build(); + s.add_diagnosis_detail(diagnosis).await?; + + Ok(()) + } + }) + .await +} diff --git a/tests/output/error.rs b/tests/output/error.rs new file mode 100644 index 0000000..19e55b4 --- /dev/null +++ b/tests/output/error.rs @@ -0,0 +1,284 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde_json::json; + +use ocptv::output::Error; + +use super::fixture::*; + +#[tokio::test] +async fn test_testrun_with_error() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "error": { + "symptom": "symptom" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output_run( + &expected, + |r, _| async move { r.add_error("symptom").await }, + ) + .await +} + +#[tokio::test] +async fn test_testrun_with_error_with_message() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "error": { + "message": "Error message", + "symptom": "symptom" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output_run(&expected, |r, _| async move { + r.add_error_msg("symptom", "Error message").await + }) + .await +} + +#[tokio::test] +async fn test_testrun_with_error_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "error": { + "message": "Error message", + "softwareInfoIds": [ + "sw0" + ], + "sourceLocation": { + "file": "file", + "line": 1 + }, + "symptom": "symptom" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output_run(&expected, |r, dut| { + async move { + r.add_error_detail( + Error::builder("symptom") + .message("Error message") + .source("file", 1) + .add_software_info(dut.software_info("sw0").unwrap()) // must exist + .build(), + ) + .await + } + }) + .await +} + +#[tokio::test] +async fn test_testrun_with_error_before_start() -> Result<()> { + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "error": { + "symptom": "no-dut", + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + ]; + + check_output(&expected, |run_builder, _| async move { + let run = run_builder.build(); + run.add_error("no-dut").await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_with_error_with_message_before_start() -> Result<()> { + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "error": { + "symptom": "no-dut", + "message": "failed to find dut", + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + ]; + + check_output(&expected, |run_builder, _| async move { + let run = run_builder.build(); + run.add_error_msg("no-dut", "failed to find dut").await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_with_error_with_details_before_start() -> Result<()> { + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "error": { + "message": "failed to find dut", + "sourceLocation": { + "file": "file", + "line": 1 + }, + "symptom": "no-dut" + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + ]; + + check_output(&expected, |run_builder, _| async move { + let run = run_builder.build(); + run.add_error_detail( + Error::builder("no-dut") + .message("failed to find dut") + .source("file", 1) + .build(), + ) + .await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_error() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "error": { + "symptom": "symptom" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_error("symptom").await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_error_with_message() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "error": { + "message": "Error message", + "symptom": "symptom" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_error_msg("symptom", "Error message").await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_error_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "error": { + "message": "Error message", + "softwareInfoIds": [ + "sw0" + ], + "sourceLocation": { + "file": "file", + "line": 1 + }, + "symptom": "symptom" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, dut| async move { + s.add_error_detail( + Error::builder("symptom") + .message("Error message") + .source("file", 1) + .add_software_info(dut.software_info("sw0").unwrap()) + .build(), + ) + .await?; + + Ok(()) + }) + .await +} diff --git a/tests/output/file.rs b/tests/output/file.rs new file mode 100644 index 0000000..7648374 --- /dev/null +++ b/tests/output/file.rs @@ -0,0 +1,84 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde_json::json; + +use ocptv::output::{File, Uri}; + +use super::fixture::*; + +#[tokio::test] +async fn test_step_with_file() -> Result<()> { + let uri = Uri::parse("file:///tmp/foo")?; + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "file": { + "displayName": "name", + "uri": uri.clone().as_str().to_owned(), + "isSnapshot": false + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_file("name", uri).await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_file_builder() -> Result<()> { + let uri = Uri::parse("file:///tmp/foo")?; + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "file": { + "displayName": "name", + "uri": uri.clone().as_str().to_owned(), + "isSnapshot": false, + "contentType": "text/plain", + "description": "description", + "metadata": { + "key": "value" + }, + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + let file = File::builder("name", uri) + .content_type(mime::TEXT_PLAIN) + .description("description") + .add_metadata("key", "value") + .build(); + s.add_file_detail(file).await?; + + Ok(()) + }) + .await +} diff --git a/tests/output/fixture.rs b/tests/output/fixture.rs new file mode 100644 index 0000000..1c25a8c --- /dev/null +++ b/tests/output/fixture.rs @@ -0,0 +1,193 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::sync::Arc; + +use anyhow::Result; +use assert_json_diff::assert_json_eq; +use futures::future::Future; +use serde_json::json; +use tokio::sync::Mutex; + +use ocptv::output::{ + Config, DutInfo, HardwareInfo, Ident, OcptvError, ScopedTestRun, ScopedTestStep, SoftwareInfo, + SoftwareType, TestResult, TestRun, TestRunBuilder, TestRunOutcome, TestStatus, + TimestampProvider, SPEC_VERSION, +}; + +pub const DATETIME: chrono::DateTime = + chrono::DateTime::from_timestamp_nanos(0); +pub const DATETIME_FORMATTED: &str = "1970-01-01T00:00:00.000Z"; +pub struct FixedTsProvider {} + +impl TimestampProvider for FixedTsProvider { + fn now(&self) -> chrono::DateTime { + // all cases will use time 0 but this is configurable + DATETIME.with_timezone(&chrono_tz::UTC) + } +} + +pub fn json_schema_version() -> serde_json::Value { + // seqno for schemaVersion is always 0 + json!({ + "schemaVersion": { + "major": SPEC_VERSION.0, + "minor": SPEC_VERSION.1 + }, + "sequenceNumber": 0, + "timestamp": DATETIME_FORMATTED + }) +} + +pub fn json_run_default_start() -> serde_json::Value { + // seqno for the default test run start is always 1 + json!({ + "testRunArtifact": { + "testRunStart": { + "dutInfo": { + "dutInfoId": "dut_id", + "softwareInfos": [{ + "softwareInfoId": "sw0", + "name": "ubuntu", + "version": "22", + "softwareType": "SYSTEM", + }], + "hardwareInfos": [{ + "hardwareInfoId": "hw0", + "name": "fan", + "location": "board0/fan" + }] + }, + "name": "run_name", + "parameters": {}, + "version": "1.0", + "commandLine": "" + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }) +} + +pub fn json_run_pass(seqno: i32) -> serde_json::Value { + json!({ + "testRunArtifact": { + "testRunEnd": { + "result": "PASS", + "status": "COMPLETE" + } + }, + "sequenceNumber": seqno, + "timestamp": DATETIME_FORMATTED + }) +} + +pub fn json_step_default_start() -> serde_json::Value { + // seqno for the default test run start is always 2 + json!({ + "testStepArtifact": { + "testStepId": "step0", + "testStepStart": { + "name": "first step" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }) +} + +pub fn json_step_complete(seqno: i32) -> serde_json::Value { + json!({ + "testStepArtifact": { + "testStepId": "step0", + "testStepEnd": { + "status": "COMPLETE" + } + }, + "sequenceNumber": seqno, + "timestamp": DATETIME_FORMATTED + }) +} + +pub async fn check_output(expected: &[serde_json::Value], test_fn: F) -> Result<()> +where + R: Future>, + F: FnOnce(TestRunBuilder, DutInfo) -> R, +{ + let buffer: Arc>> = Arc::new(Mutex::new(vec![])); + let mut dut = DutInfo::builder("dut_id").build(); + dut.add_software_info( + SoftwareInfo::builder("ubuntu") + .id(Ident::Exact("sw0".to_owned())) // name is important as fixture + .version("22") + .software_type(SoftwareType::System) + .build(), + ); + dut.add_hardware_info( + HardwareInfo::builder("fan") + .id(Ident::Exact("hw0".to_owned())) + .location("board0/fan") + .build(), + ); + + let run_builder = TestRun::builder("run_name", "1.0").config( + Config::builder() + .with_buffer_output(Arc::clone(&buffer)) + .with_timestamp_provider(Box::new(FixedTsProvider {})) + .build(), + ); + + // run the main test closure + test_fn(run_builder, dut).await?; + + for (i, entry) in buffer.lock().await.iter().enumerate() { + let value = serde_json::from_str::(entry)?; + assert_json_eq!(value, expected[i]); + } + + Ok(()) +} + +pub async fn check_output_run(expected: &[serde_json::Value], test_fn: F) -> Result<()> +where + R: Future> + Send + 'static, + F: FnOnce(ScopedTestRun, DutInfo) -> R + Send + 'static, +{ + check_output(expected, |run_builder, dut| async move { + run_builder + .build() + .scope(dut.clone(), |run| async move { + test_fn(run, dut).await?; + Ok(TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) + }) + .await +} + +pub async fn check_output_step(expected: &[serde_json::Value], test_fn: F) -> Result<()> +where + R: Future> + Send + 'static, + F: FnOnce(ScopedTestStep, DutInfo) -> R + Send + 'static, +{ + check_output_run(expected, |run, dut| async move { + run.add_step("first step") + .scope(|step| async move { + test_fn(step, dut).await?; + + Ok(TestStatus::Complete) + }) + .await?; + + Ok(()) + }) + .await +} diff --git a/tests/output/log.rs b/tests/output/log.rs new file mode 100644 index 0000000..cc8fc2c --- /dev/null +++ b/tests/output/log.rs @@ -0,0 +1,146 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde_json::json; + +use ocptv::output::{Log, LogSeverity}; + +use super::fixture::*; + +#[tokio::test] +async fn test_testrun_with_log() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "log": { + "message": "This is a log message with INFO severity", + "severity": "INFO" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output_run(&expected, |r, _| async move { + r.add_log( + LogSeverity::Info, + "This is a log message with INFO severity", + ) + .await + }) + .await +} + +#[tokio::test] +async fn test_testrun_with_log_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "log": { + "message": "This is a log message with INFO severity", + "severity": "INFO", + "sourceLocation": { + "file": "file", + "line": 1 + } + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output_run(&expected, |r, _| async move { + r.add_log_detail( + Log::builder("This is a log message with INFO severity") + .severity(LogSeverity::Info) + .source("file", 1) + .build(), + ) + .await + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_log() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "log": { + "message": "This is a log message with INFO severity", + "severity": "INFO" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_log( + LogSeverity::Info, + "This is a log message with INFO severity", + ) + .await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_log_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "log": { + "message": "This is a log message with INFO severity", + "severity": "INFO", + "sourceLocation": { + "file": "file", + "line": 1 + } + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_log_detail( + Log::builder("This is a log message with INFO severity") + .severity(LogSeverity::Info) + .source("file", 1) + .build(), + ) + .await?; + + Ok(()) + }) + .await +} diff --git a/tests/output/macros.rs b/tests/output/macros.rs new file mode 100644 index 0000000..9762626 --- /dev/null +++ b/tests/output/macros.rs @@ -0,0 +1,426 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::future::Future; +use std::sync::Arc; + +use anyhow::anyhow; +use anyhow::Result; +use assert_json_diff::assert_json_include; +use serde_json::json; +use tokio::sync::Mutex; + +use ocptv::ocptv_error; +use ocptv::output as tv; +use ocptv::{ + ocptv_diagnosis_fail, ocptv_diagnosis_pass, ocptv_diagnosis_unknown, ocptv_log_debug, + ocptv_log_error, ocptv_log_fatal, ocptv_log_info, ocptv_log_warning, +}; +use tv::{Config, DutInfo, StartedTestRun, StartedTestStep, TestRun}; + +async fn check_output( + expected: &serde_json::Value, + func: F, +) -> Result +where + R: Future>, + F: FnOnce(StartedTestRun) -> R, +{ + let buffer: Arc>> = Arc::new(Mutex::new(vec![])); + + let dut = DutInfo::builder("dut_id").build(); + let run = TestRun::builder("run_name", "1.0") + .config(Config::builder().with_buffer_output(buffer.clone()).build()) + .build() + .start(dut) + .await?; + + func(run).await?; + + let actual = serde_json::from_str::( + &buffer + .lock() + .await + // first 2 items are schemaVersion, testRunStart + .first_chunk::() + .ok_or(anyhow!("buffer is missing macro output item"))?[N - 1], + )?; + assert_json_include!(actual: actual.clone(), expected: expected); + + Ok(actual) +} + +async fn check_output_run(expected: &serde_json::Value, key: &str, func: F) -> Result<()> +where + R: Future>, + F: FnOnce(StartedTestRun) -> R, +{ + let actual = check_output::<_, _, 3>(expected, func).await?; + + let source = actual + .get("testRunArtifact") + .ok_or(anyhow!("testRunArtifact key does not exist"))? + .get(key) + .ok_or(anyhow!("error key does not exist"))?; + + assert_ne!( + source.get("sourceLocation"), + None, + "sourceLocation is not present in the serialized object" + ); + + Ok(()) +} + +async fn check_output_step(expected: &serde_json::Value, key: &str, func: F) -> Result<()> +where + R: Future>, + F: FnOnce(StartedTestStep) -> R, +{ + let actual = check_output::<_, _, 4>(expected, |run| async move { + let step = run.add_step("step_name").start().await?; + + func(step).await?; + Ok(()) + }) + .await?; + + let source = actual + .get("testStepArtifact") + .ok_or(anyhow!("testRunArtifact key does not exist"))? + .get(key) + .ok_or(anyhow!("error key does not exist"))?; + + assert_ne!( + source.get("sourceLocation"), + None, + "sourceLocation is not present in the serialized object" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_ocptv_error_macro_with_symptom_and_message() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "error": { + "message": "Error message", + "symptom": "symptom" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "error", |run| async move { + ocptv_error!(run, "symptom", "Error message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_error_macro_with_symptom() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "error": { + "symptom": "symptom" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "error", |run| async move { + ocptv_error!(run, "symptom").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_debug() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "log": { + "message": "log message", + "severity": "DEBUG" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "log", |run| async move { + ocptv_log_debug!(run, "log message").await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_info() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "log": { + "message": "log message", + "severity": "INFO" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "log", |run| async move { + ocptv_log_info!(run, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_warning() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "log": { + "message": "log message", + "severity": "WARNING" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "log", |run| async move { + ocptv_log_warning!(run, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_error() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "log": { + "message": "log message", + "severity": "ERROR" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "log", |run| async move { + ocptv_log_error!(run, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_fatal() -> Result<()> { + let expected = json!({ + "testRunArtifact": { + "log": { + "message": "log message", + "severity": "FATAL" + } + }, + "sequenceNumber": 2 + }); + + check_output_run(&expected, "log", |run| async move { + ocptv_log_fatal!(run, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_error_macro_with_symptom_and_message_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "error": { + "message": "Error message", + "symptom":"symptom" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "error", |step| async move { + ocptv_error!(step, "symptom", "Error message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_error_macro_with_symptom_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "error": { + "symptom": "symptom" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "error", |step| async move { + ocptv_error!(step, "symptom").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_debug_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "log": { + "message": "log message", + "severity": "DEBUG" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "log", |step| async move { + ocptv_log_debug!(step, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_info_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "log": { + "message": "log message", + "severity": "INFO" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "log", |step| async move { + ocptv_log_info!(step, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_warning_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "log": { + "message": "log message", + "severity":"WARNING" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "log", |step| async move { + ocptv_log_warning!(step, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_error_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "log": { + "message": "log message", + "severity": "ERROR" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "log", |step| async move { + ocptv_log_error!(step, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_log_fatal_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "log": { + "message": "log message", + "severity": "FATAL" + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "log", |step| async move { + ocptv_log_fatal!(step, "log message").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_diagnosis_pass_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "diagnosis": { + "verdict": "verdict", + "type": "PASS", + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "diagnosis", |step| async move { + ocptv_diagnosis_pass!(step, "verdict").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_diagnosis_fail_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "diagnosis": { + "verdict": "verdict", + "type": "FAIL", + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "diagnosis", |step| async move { + ocptv_diagnosis_fail!(step, "verdict").await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_ocptv_diagnosis_unknown_in_step() -> Result<()> { + let expected = json!({ + "testStepArtifact": { + "diagnosis": { + "verdict": "verdict", + "type": "UNKNOWN", + } + }, + "sequenceNumber": 3 + }); + + check_output_step(&expected, "diagnosis", |step| async move { + ocptv_diagnosis_unknown!(step, "verdict").await?; + Ok(()) + }) + .await +} diff --git a/tests/output/main.rs b/tests/output/main.rs new file mode 100644 index 0000000..dd6f89e --- /dev/null +++ b/tests/output/main.rs @@ -0,0 +1,16 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +mod config; +mod diagnosis; +mod error; +mod file; +mod fixture; +mod log; +mod macros; +mod measure; +mod run; +mod step; diff --git a/tests/output/measure.rs b/tests/output/measure.rs new file mode 100644 index 0000000..371c16c --- /dev/null +++ b/tests/output/measure.rs @@ -0,0 +1,680 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use anyhow::Result; +use serde_json::json; + +use ocptv::output::{ + Ident, Measurement, MeasurementElementDetail, MeasurementSeriesDetail, Subcomponent, Validator, + ValidatorType, +}; + +use super::fixture::*; + +#[tokio::test] +async fn test_step_with_measurement() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurement": { + "name": "name", + "value": 50 + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_measurement("name", 50).await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_builder() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurement": { + "name": "name", + "value": 50, + "validators": [{ + "type": "EQUAL", + "value": 30 + }], + "hardwareInfoId": "hw0", + "subcomponent": { + "name": "name" + }, + "metadata": { + "key": "value", + "key2": "value2" + } + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_step(&expected, |s, dut| { + async move { + let hw_info = dut.hardware_info("hw0").unwrap(); // must exist + + let measurement = Measurement::builder("name", 50) + .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .hardware_info(hw_info) + .subcomponent(Subcomponent::builder("name").build()) + .build(); + s.add_measurement_detail(measurement).await?; + + Ok(()) + } + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 0 + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(5), + json_run_pass(6), + ]; + + check_output_step(&expected, |s, _| async move { + let series = s.add_measurement_series("name").start().await?; + series.end().await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_multiple_measurement_series() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 0 + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series1", + "name": "name" + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series1", + "totalCount": 0 + } + }, + "sequenceNumber": 6, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(7), + json_run_pass(8), + ]; + + check_output_step(&expected, |s, _| async move { + let series = s.add_measurement_series("name").start().await?; + series.end().await?; + + let series_2 = s.add_measurement_series("name").start().await?; + series_2.end().await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "series_id", + "name": "name", + "unit": "unit", + "validators": [{ + "type": "EQUAL", + "value": 30 + }], + "hardwareInfoId": "hw0", + "subcomponent": { + "name": "name" + }, + "metadata": { + "key": "value", + "key2": "value2" + } + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "series_id", + "totalCount": 0 + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(5), + json_run_pass(6), + ]; + + check_output_step(&expected, |s, dut| { + async move { + let hw_info = dut.hardware_info("hw0").unwrap(); // must exist + + let series = s + .add_measurement_series_detail( + MeasurementSeriesDetail::builder("name") + .id(Ident::Exact("series_id".to_owned())) + .unit("unit") + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .add_validator(Validator::builder(ValidatorType::Equal, 30).build()) + .hardware_info(hw_info) + .subcomponent(Subcomponent::builder("name").build()) + .build(), + ) + .start() + .await?; + series.end().await?; + + Ok(()) + } + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_element() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 0, + "measurementSeriesId": "step0_series0", + "value": 60, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 1 + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(6), + json_run_pass(7), + ]; + + check_output_step(&expected, |s, _| async move { + let series = s.add_measurement_series("name").start().await?; + series.add_measurement(60).await?; + series.end().await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_element_index_no() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 0, + "measurementSeriesId": "step0_series0", + "value": 60, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 1, + "measurementSeriesId": "step0_series0", + "value": 70, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 2, + "measurementSeriesId": "step0_series0", + "value": 80, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 6, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 3 + } + }, + "sequenceNumber": 7, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(8), + json_run_pass(9), + ]; + + check_output_step(&expected, |s, _| { + async move { + let series = s.add_measurement_series("name").start().await?; + // add more than one element to check the index increments correctly + series.add_measurement(60).await?; + series.add_measurement(70).await?; + series.add_measurement(80).await?; + series.end().await?; + + Ok(()) + } + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_element_with_details() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 0, + "measurementSeriesId": "step0_series0", + "metadata": { + "key": "value", + "key2": "value2" + }, + "value": 60, + "timestamp": DATETIME_FORMATTED, + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 1 + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(6), + json_run_pass(7), + ]; + + check_output_step(&expected, |s, _| async move { + s.add_measurement_series("name") + .scope(|s| async move { + s.add_measurement_detail( + MeasurementElementDetail::builder(60) + .timestamp(DATETIME.with_timezone(&chrono_tz::UTC)) + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .build(), + ) + .await?; + + Ok(()) + }) + .await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_element_with_metadata_index_no() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 0, + "measurementSeriesId": "step0_series0", + "metadata": {"key": "value"}, + "value": 60, + "timestamp": DATETIME_FORMATTED, + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 1, + "measurementSeriesId": "step0_series0", + "metadata": {"key2": "value2"}, + "value": 70, + "timestamp": DATETIME_FORMATTED, + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 2, + "measurementSeriesId": "step0_series0", + "metadata": {"key3": "value3"}, + "value": 80, + "timestamp": DATETIME_FORMATTED, + } + }, + "sequenceNumber": 6, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 3 + } + }, + "sequenceNumber": 7, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(8), + json_run_pass(9), + ]; + + check_output_step(&expected, |s, _| { + async move { + let series = s.add_measurement_series("name").start().await?; + // add more than one element to check the index increments correctly + series + .add_measurement_detail( + MeasurementElementDetail::builder(60) + .add_metadata("key", "value") + .build(), + ) + .await?; + series + .add_measurement_detail( + MeasurementElementDetail::builder(70) + .add_metadata("key2", "value2") + .build(), + ) + .await?; + series + .add_measurement_detail( + MeasurementElementDetail::builder(80) + .add_metadata("key3", "value3") + .build(), + ) + .await?; + series.end().await?; + + Ok(()) + } + }) + .await +} + +#[tokio::test] +async fn test_step_with_measurement_series_scope() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesStart": { + "measurementSeriesId": "step0_series0", + "name": "name" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 0, + "measurementSeriesId": "step0_series0", + "value": 60, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 4, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 1, + "measurementSeriesId": "step0_series0", + "value": 70, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 5, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesElement": { + "index": 2, + "measurementSeriesId": "step0_series0", + "value": 80, + "timestamp": DATETIME_FORMATTED + } + }, + "sequenceNumber": 6, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "measurementSeriesEnd": { + "measurementSeriesId": "step0_series0", + "totalCount": 3 + } + }, + "sequenceNumber": 7, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(8), + json_run_pass(9), + ]; + + check_output_step(&expected, |s, _| async move { + let series = s.add_measurement_series("name"); + series + .scope(|s| async move { + s.add_measurement(60).await?; + s.add_measurement(70).await?; + s.add_measurement(80).await?; + + Ok(()) + }) + .await?; + + Ok(()) + }) + .await +} diff --git a/tests/output/run.rs b/tests/output/run.rs new file mode 100644 index 0000000..c7e8af3 --- /dev/null +++ b/tests/output/run.rs @@ -0,0 +1,189 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::sync::Arc; + +use anyhow::Result; +use assert_json_diff::assert_json_include; +use serde_json::json; +use tokio::sync::Mutex; + +use ocptv::output::{DutInfo, TestResult, TestRun, TestStatus}; + +use super::fixture::*; + +#[tokio::test] +async fn test_testrun_start_and_end() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_run_pass(2), + ]; + + check_output_run(&expected, |_, _| async { Ok(()) }).await +} + +#[tokio::test] +async fn test_testrun_with_scope() -> Result<()> { + use ocptv::output::{LogSeverity, TestResult, TestRunOutcome, TestStatus}; + + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testRunArtifact": { + "log": { + "message": "First message", + "severity": "INFO" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(3), + ]; + + check_output(&expected, |run_builder, dut| async { + let run = run_builder.build(); + + run.scope(dut, |r| async move { + r.add_log(LogSeverity::Info, "First message").await?; + + Ok(TestRunOutcome { + status: TestStatus::Complete, + result: TestResult::Pass, + }) + }) + .await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_instantiation_with_new() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_run_pass(2), + ]; + let buffer: Arc>> = Arc::new(Mutex::new(vec![])); + + let dut = DutInfo::builder("dut_id").build(); + let run = TestRun::new("run_name", "1.0").start(dut).await?; + run.end(TestStatus::Complete, TestResult::Pass).await?; + + for (idx, entry) in buffer.lock().await.iter().enumerate() { + let value = serde_json::from_str::(entry)?; + assert_json_include!(actual: value, expected: &expected[idx]); + } + + Ok(()) +} + +#[tokio::test] +async fn test_testrun_metadata() -> Result<()> { + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "testRunStart": { + "dutInfo": { + "dutInfoId": "dut_id", + "softwareInfos": [{ + "softwareInfoId": "sw0", + "name": "ubuntu", + "version": "22", + "softwareType": "SYSTEM", + }], + "hardwareInfos": [{ + "hardwareInfoId": "hw0", + "name": "fan", + "location": "board0/fan" + }] + }, + "metadata": {"key": "value"}, + "name": "run_name", + "parameters": {}, + "version": "1.0", + + "commandLine": "", + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(2), + ]; + + check_output(&expected, |run_builder, dut| async { + let run = run_builder + .add_metadata("key", "value") + .build() + .start(dut) + .await?; + + run.end(TestStatus::Complete, TestResult::Pass).await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_builder() -> Result<()> { + let expected = [ + json_schema_version(), + json!({ + "testRunArtifact": { + "testRunStart": { + "commandLine": "cmd_line", + "dutInfo": { + "dutInfoId": "dut_id", + "softwareInfos": [{ + "softwareInfoId": "sw0", + "name": "ubuntu", + "version": "22", + "softwareType": "SYSTEM", + }], + "hardwareInfos": [{ + "hardwareInfoId": "hw0", + "name": "fan", + "location": "board0/fan" + }] + }, + "metadata": { + "key": "value", + "key2": "value2" + }, + "name": "run_name", + "parameters": { + "key": "value" + }, + "version": "1.0" + } + }, + "sequenceNumber": 1, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(2), + ]; + + check_output(&expected, |run_builder, dut| async { + let run = run_builder + .add_metadata("key", "value") + .add_metadata("key2", "value2") + .add_parameter("key", "value") + .command_line("cmd_line") + .build() + .start(dut) + .await?; + + run.end(TestStatus::Complete, TestResult::Pass).await?; + Ok(()) + }) + .await +} diff --git a/tests/output/step.rs b/tests/output/step.rs new file mode 100644 index 0000000..bf0b358 --- /dev/null +++ b/tests/output/step.rs @@ -0,0 +1,191 @@ +// (c) Meta Platforms, Inc. and affiliates. +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +use std::sync::Arc; + +use anyhow::Result; +use serde_json::json; +use tokio::sync::Mutex; + +use ocptv::output::{Config, DutInfo, OcptvError, TestRun, TestStatus}; + +use super::fixture::*; + +#[tokio::test] +async fn test_testrun_with_step() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "testStepStart": { + "name": "first step" + } + }, + "sequenceNumber": 2, + "timestamp": DATETIME_FORMATTED + }), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "testStepEnd": { + "status": "COMPLETE" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_run_pass(4), + ]; + + check_output_run(&expected, |r, _| async move { + let step = r.add_step("first step").start().await?; + step.end(TestStatus::Complete).await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_testrun_step_scope_log() -> Result<()> { + use ocptv::output::{LogSeverity, TestStatus}; + + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "log": { + "message": "This is a log message with INFO severity", + "severity": "INFO" + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + check_output_run(&expected, |r, _| async move { + r.add_step("first step") + .scope(|s| async move { + s.add_log( + LogSeverity::Info, + "This is a log message with INFO severity", + ) + .await?; + + Ok(TestStatus::Complete) + }) + .await + }) + .await +} + +#[tokio::test] +async fn test_step_with_extension() -> Result<()> { + let expected = [ + json_schema_version(), + json_run_default_start(), + json_step_default_start(), + json!({ + "testStepArtifact": { + "testStepId": "step0", + "extension": { + "name": "extension", + "content": { + "@type": "TestExtension", + "stringField": "string", + "numberField": 42 + } + } + }, + "sequenceNumber": 3, + "timestamp": DATETIME_FORMATTED + }), + json_step_complete(4), + json_run_pass(5), + ]; + + #[derive(serde::Serialize)] + struct Ext { + #[serde(rename = "@type")] + r#type: String, + #[serde(rename = "stringField")] + string_field: String, + #[serde(rename = "numberField")] + number_field: u32, + } + + check_output_step(&expected, |s, _| async move { + s.add_extension( + "extension", + Ext { + r#type: "TestExtension".to_owned(), + string_field: "string".to_owned(), + number_field: 42, + }, + ) + .await?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn test_step_with_extension_which_fails() -> Result<()> { + #[derive(thiserror::Error, Debug, PartialEq)] + enum TestError { + #[error("test_error_fail")] + Fail, + } + + fn fail_serialize(_: &u32, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom(TestError::Fail)) + } + + #[derive(serde::Serialize)] + struct Ext { + #[serde(serialize_with = "fail_serialize")] + i: u32, + } + + let buffer: Arc>> = Arc::new(Mutex::new(vec![])); + let dut = DutInfo::builder("dut_id").build(); + let run = TestRun::builder("run_name", "1.0") + .config( + Config::builder() + .with_buffer_output(Arc::clone(&buffer)) + .with_timestamp_provider(Box::new(FixedTsProvider {})) + .build(), + ) + .build() + .start(dut) + .await?; + let step = run.add_step("first step").start().await?; + + let result = step.add_extension("extension", Ext { i: 0 }).await; + + match result { + Err(OcptvError::Format(e)) => { + // `to_string` is the only way to check this error. `serde_json::Error` only + // implements source/cause for io errors, and this is a string + assert_eq!(e.to_string(), "test_error_fail"); + } + _ => panic!("unexpected ocptv error type"), + } + + Ok(()) +}