diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..42e4647 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,37 @@ +--- +name: Bug +about: Report a bug +labels: bug +--- + + + + + + +### Reproduction Steps + + + +### Expected Behavior + + + +### Actual Behavior + + + +### Additional Information + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 0000000..2b2fa10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,35 @@ +--- +name: Enhancement +about: Suggest an enhancement +labels: enhancement +--- + + + + + + +### Description + + + +### Why? + + + +### Alternatives + + + +### Additional Information + + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..79b9f7a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,5 @@ +--- +name: Question +about: Ask a question +labels: question +--- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..78e4e8d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ + + +### Issue + + + +### Drawbacks + + + +### Tests + + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..59ff229 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" +jobs: + GitHub: + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v3.0.2 + - run: > + curl + --fail + --no-progress-meter + --request POST + --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" + --header "Accept: application/vnd.github.v3+json" + --data-raw '{"tag_name":"${{ github.ref_name }}","body":"Check the [changelog](CHANGELOG.md) for more details."}' + https://api.github.com/repos/${{ github.repository }}/releases diff --git a/.github/workflows/sqm.yml b/.github/workflows/sqm.yml new file mode 100644 index 0000000..a71014a --- /dev/null +++ b/.github/workflows/sqm.yml @@ -0,0 +1,11 @@ +name: Software Quality Management +on: [pull_request, push] +jobs: + Testing: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3.0.2 + - run: sbin/provision + - run: sbin/format -c + - run: sbin/lint + - run: sbin/test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a977916 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vagrant/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a645f1b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/TobyGiacometti/test.sh/compare/1.0.0...HEAD diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f50576a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the project community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..29c1a56 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,168 @@ +# Contribution Guide + +First of all, thanks for your interest and for taking the time to contribute! This document shall be your guide throughout the contribution process and will hopefully answer any questions you have. + +## Table of Contents + +- [Response Times](#response-times) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Enhancements](#suggesting-enhancements) +- [Contributing Changes](#contributing-changes) + - [Prerequisites](#prerequisites) + - [Development Environment](#development-environment) + - [Conventions](#conventions) + - [General](#general) + - [Unix Shell Script](#unix-shell-script) + - [Testing](#testing) + - [Pull Request](#pull-request) +- [Code of Conduct](#code-of-conduct) +- [Questions](#questions) + +## Response Times + +This project has been made available to you without expecting anything in return. As a result, maintenance does not happen on a set schedule. Please keep in mind that the irregular maintenance schedule can lead to significant delays in response times. + +## Reporting Bugs + +Before reporting a bug, please check if the bug occurs in the latest version. If it does, and if it hasn't already been reported in the [bug tracker][1], feel free to [file a bug report][2]. + +## Suggesting Enhancements + +> **Note:** Simplicity is a core principle of this project. Every enhancement suggestion is carefully evaluated and only accepted if the usefulness of the enhancement greatly outweighs any increase in complexity. + +Before suggesting an enhancement, please ensure that the enhancement is not implemented in the latest version. In addition, please ensure that there is no straightforward alternative to achieve the desired outcome. If these conditions are met, and if the enhancement hasn't already been suggested in the [enhancement tracker][3], feel free to [file an enhancement suggestion][4]. + +## Contributing Changes + +### Prerequisites + +Before making changes that you plan to contribute, please follow these instructions: + +- **Changes related to a [reported bug][1]:** Make sure that the bug has not yet been assigned to anybody (and that nobody has volunteered) and write a comment letting the community know that you have decided to fix the bug. +- **Changes related to an unreported bug:** [File a bug report][2]. +- **Changes related to an [already suggested enhancement][3]:** Make sure that the enhancement has not yet been assigned to anybody and write a comment letting the maintainers know that you would like to implement the enhancement. Wait until the enhancement suggestion has been assigned to you. +- **Changes related to a not yet suggested enhancement:** [File an enhancement suggestion][4] and wait until it has been assigned to you. + +Following these instructions keeps you (and others) from investing time in changes that would get rejected or are already being worked on. + +### Development Environment + +This project uses [Vagrant][5] to manage a portable development environment. Simply execute `vagrant up` inside the project's directory to start the setup. Once completed, you can access the development environment with `vagrant ssh`. + +### Conventions + +#### General + +- Code *should* document itself (meaningful naming). +- Code *must* be formatted by executing `vagrant ssh -c /mnt/project/sbin/format` inside the project's directory. + +#### Unix Shell Script + +- The [general conventions][6] *must* be followed. +- Lines longer than 80 characters *should* be avoided. +- Unless the script is inside the `sbin` or `t` directories, it *must* have following file header: + + ```sh + # shellcheck shell=sh + + # test.sh + # https://github.com/TobyGiacometti/test.sh + # Copyright (c) Toby Giacometti and contributors + # Apache License 2.0 + ``` + + The file header *must* be separated from other elements with an empty line. + +- Commands *must* be grouped and ordered as follows and groups *must* be separated from each other with an empty line: + 1. Environment checks (check if OS is supported, etc.) + 2. Shell option setting/unsetting + 3. File sourcing + 4. Public function definitions + 5. Internal function definitions + 6. Trap registrations + 7. Common public variable assignments + 8. Common internal variable assignments + 9. Main logic +- Functions *must* be separated from each other with an empty line. +- Function and variable names *must* use snake case. +- If the script is to be sourced by end users, functions and variables that are not intended for public use *must* be named with the `_testsh_` prefix. +- Names of functions that make modifications *must* read as imperative verb phrases. For example: `print_help`, `fork`. +- Names of functions that don't make modifications *must* read as [predicate phrases][7]. For example: `is_empty`, `exists`. +- Functions *must* be documented using Markdown syntax and following template: + + ```sh + #--- + # Summary for function (if not obvious or description is provided). + # + # Description for function (if extended documentation is needed). + # + # @param $@ Description for all parameters (if function takes multiple arguments that are all of the same type). + # @param $ Description for a parameter (if not using a description for all parameters). + # @param... Description for remaining parameters (if function takes multiple trailing arguments that are all of the same type). + # @stdin Description for STDIN (if used). + # @stdout Description for STDOUT (if used). + # @stderr Description for STDERR (if used for non-error output). + # @fd Description for a non-standard file descriptor. + # @status Description for all status codes (if documenting each status code separately is suboptimal). + # @status Description for a non-standard status code (if not using a description for all status codes). + # @exit (if function calls `exit` outside of error cases) + # @internal (if function is not intended for public use) + func() { :; } + ``` + +- If the script is to be sourced by end users, global variables *must* be documented using Markdown syntax and following template: + + ```sh + #--- + # @var Description for variable (if not obvious). + # @internal (if variable is not intended for public use) + var= + #--- + # @var $var_ + # Description for placeholder. + # Description for dynamically defined variable (if not obvious). + # @readonly (if variable is read-only) + # @internal (if variable is not intended for public use) + ``` + +### Testing + +The tests are stored inside the `t` directory. Simply execute `vagrant ssh -c /mnt/project/sbin/test` inside the project's directory to run the test suite. + +### Pull Request + +Before creating a pull request, please follow these instructions: + +- Recreate the development environment if `sbin/provision` has been modified. +- Ensure that the instructions in the [Prerequisites][8] and [Conventions][9] sections have been followed. +- Lint the codebase by executing `vagrant ssh -c /mnt/project/sbin/lint` inside the project's directory. +- Update the [test suite][10] and exercise the code you have written. +- Update the [README file][11]. +- Update the [changelog][12]. + +After the pull request has been created, confirm that all [status checks][13] are passing. If you believe that a status check failure is a false positive, comment on the pull request and a maintainer will review the failure. + +## Code of Conduct + +Please note that this project is released with a [contributer code of conduct][14]. By participating in this project you agree to abide by its terms. + +## Questions + +Still have questions? No problem! Use the [question tracker][15] to [ask a question][16]. + +[1]: https://github.com/TobyGiacometti/test.sh/issues?q=is%3Aissue+label%3Abug +[2]: https://github.com/TobyGiacometti/test.sh/issues/new?template=bug.md +[3]: https://github.com/TobyGiacometti/test.sh/issues?q=is%3Aissue+label%3Aenhancement +[4]: https://github.com/TobyGiacometti/test.sh/issues/new?template=enhancement.md +[5]: https://www.vagrantup.com +[6]: #general +[7]: https://en.wikipedia.org/wiki/Predicate_(grammar) +[8]: #prerequisites +[9]: #conventions +[10]: #testing +[11]: README.md +[12]: CHANGELOG.md +[13]: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-status-checks +[14]: CODE_OF_CONDUCT.md +[15]: https://github.com/TobyGiacometti/test.sh/issues?q=is%3Aissue+label%3Aquestion +[16]: https://github.com/TobyGiacometti/test.sh/issues/new?template=question.md diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0c7dae --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# test.sh + +A Unix shell library that turns shell scripts into test runners. + +## Table of Contents + +- [Why?](#why) +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) + - [Creating Tests](#creating-tests) + - [Setup and Teardown](#setup-and-teardown) + - [Skipping Tests](#skipping-tests) + - [Failing Tests](#failing-tests) + - [Todo Tests](#todo-tests) + - [Abort](#abort) + - [Test Data Storage](#test-data-storage) + - [Diagnostic Output](#diagnostic-output) + - [Running Tests](#running-tests) + - [Example](#example) + +## Why? + +While many testing frameworks for the Unix shell are available, most of them share two traits: They are not portable and introduce new syntax. test.sh adopts a more universal approach by offering a wide range of useful testing features while supporting any POSIX-compatible shell and standard shell syntax. + +## Features + +- Simple architecture: Create an empty shell script, source test.sh and define tests (shell functions). Once done, simply execute the script to start testing. +- Various hooks for different stages of the testing process are available. +- Temporary directories for test data storage are automatically created/removed. +- Tests can be skipped or aborted easily. +- [TAP output][1] with helpful diagnostic information is produced. + +## Requirements + +test.sh should work in any POSIX-compatible shell (tested with posh, dash, ksh93 and bash). + +## Installation + +Installing test.sh is as simple as storing the file [test.sh][2] in a location of choice. + +## Usage + +### Creating Tests + +Any test file function whose name starts with `test` represents a single test. Each test function runs in a separate subshell and any return code other than 0 is interpreted as a test failure: + +```sh +#!/bin/sh + +. /path/to/test.sh + +test_feature_one() { + return 0 # Pass +} + +test_feature_two() { + return 1 # Fail +} + +run_tests "$0" +``` + +> **Note:** Test functions must be defined using POSIX-compliant syntax. Any test function that is defined with the syntax `function fname compound-command` won't be detected. + +### Setup and Teardown + +Special functions can be defined in test files to execute actions during testing: + +- `setup_test_file()`: Invoked before tests in a test file run. Any return code other than 0 skips all tests in the test file. +- `teardown_test_file()`: Invoked before a test file exits. +- `setup_test()`: Invoked before each test runs. Any return code other than 0 skips the test and marks it as failed. +- `teardown_test()`: Invoked before a test subshell exits. Any return code other than 0 marks the test as failed. + +You can use the variable `$test_func` inside `setup_test()` and `teardown_test()` if you need to know which test function is currently active. + +### Skipping Tests + +`skip_test` can be called inside `setup_test()` or a test function to skip the current test. If called inside `setup_test_file()`, all tests in the current test file are skipped. If desired, you can provide a reason as the first argument. + +> **Note:** `skip_test` calls `exit`. If called inside a subshell, the `exit` call needs to be propagated. + +### Failing Tests + +In addition to calling `return` or `exit` with a non-zero status code, you can also call `fail_test` to fail tests: + +```sh +#!/bin/sh + +. /path/to/test.sh + +test_feature_one() { + # A code that helps to identify what caused the fail must be provided as + # the first argument. Additional information can be provided after the + # code (optional). + printf "%s\n" "An error occurred" >&2 + fail_test 1 "Additional information" "Some more information" +} + +run_tests "$0" +``` + +`fail_test` automatically generates well formatted diagnostic output: + +``` +not ok 1 test_feature_one +# FAIL 1 +# --- +# An error occurred +# Additional information +# Some more information +1..1 +``` + +`fail_test` can also be called inside `setup_test()` or `teardown_test()`. + +> **Note:** `fail_test` calls `exit`. If called inside a subshell, the `exit` call needs to be propagated. + +### Todo Tests + +`mark_test_todo` can be called inside `setup_test()`, `teardown_test()` or a test function if code under test is not yet complete (failures occur) but the test should pass regardless. More information can be found in the [TAP specification][3]. If desired, you can provide a reason as the first argument. + +### Abort + +`abort_testing` can be called at any time to abort the testing process. Once called, the current test (if one is running) is aborted and any remaining tests in the current test file are not invoked. If a [TAP harness][4] is being used to invoke tests, any remaining test files are not processed. If desired, you can provide a reason as the first argument. + +> **Note:** `abort_testing` calls `exit`. If called inside a subshell, the `exit` call needs to be propagated. + +### Test Data Storage + +Each test file run gets its own temporary directory for test data storage. The path to the directory is stored in the variable `$test_data_dir`. The directory is automatically created and removed. + +Whenever possible, test data should be scoped to test functions to isolate tests from each other. This can be achieved by naming test data files/directories using the current test function name. For this purpose, the variable `$test_func` can be used inside `setup_test()`, `teardown_test()` and test functions. + +### Diagnostic Output + +Any output that is sent to STDOUT or STDERR when inside `setup_test_file()`, `setup_test()`, `teardown_test()`, `teardown_test_file()` or a test function is recorded and automatically printed as [TAP diagnostics][5]. + +> **Note:** Output that is generated outside of the said functions will not be processed. Depending on the [TAP harness][4], this could lead to an error. + +### Running Tests + +To run tests, you can simply execute any test file that is powered by test.sh. If one of the tests in the file fails, the file will exit with a non-zero status code: + +``` +$ t/run.sh +not ok 1 test_missing_path_failure +ok 2 test_call +1..2 +$ echo "$?" +1 +``` + +Since [TAP output][1] is produced, test files can be processed by a [TAP harness][4]. The command-line utility [prove][6], which is preinstalled on many Unix operating systems, can be used for this purpose: + +``` +$ prove t/*.sh +t/abort.sh ..... ok +t/data_dir.sh .. ok +t/fail.sh ...... ok +t/run.sh ....... ok +t/setup.sh ..... ok +t/skip.sh ...... ok +t/teardown.sh .. ok +t/todo.sh ...... ok +All tests successful. +Files=8, Tests=34, 1 wallclock secs ( 0.06 usr 0.04 sys + 0.41 cusr 0.17 csys = 0.68 CPU) +Result: PASS +``` + +> **Note:** Don't forget to call `run_tests "$0"` at the end of a test file. + +### Example + +Check out the [test suite for toolbelt.sh][7] to see test.sh in action. + +[1]: https://testanything.org/tap-specification.html +[2]: test.sh +[3]: https://testanything.org/tap-specification.html#todo-tests +[4]: https://testanything.org/consumers.html +[5]: https://testanything.org/tap-specification.html#diagnostics +[6]: https://perldoc.perl.org/prove.html +[7]: https://github.com/TobyGiacometti/toolbelt.sh/tree/master/t diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5b5089a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting Vulnerabilities + +Please notify the maintainers at if you have discovered a security issue in this project. + +> **Note:** Public disclosure before availability of a fix is highly discouraged. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..371d334 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,23 @@ +$memory = 512 + +Vagrant.configure("2") do |config| + config.vm.box = "bento/ubuntu-22.04" + config.vm.provider "virtualbox" do |vm| + vm.memory = $memory + end + config.vm.provider "vmware_desktop" do |vm| + vm.vmx["memsize"] = $memory.to_s + end + config.vm.provider "parallels" do |vm| + vm.memory = $memory + end + config.vm.provider "hyperv" do |vm| + vm.maxmemory = $memory + end + config.vm.synced_folder ".", "/vagrant", disabled: true + config.vm.synced_folder ".", "/mnt/project" + config.vm.provision "shell" do |sh| + sh.inline = "/mnt/project/sbin/provision" + sh.privileged = false + end +end diff --git a/sbin/format b/sbin/format new file mode 100755 index 0000000..0f0f876 --- /dev/null +++ b/sbin/format @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# The curly braces (with the final `exit`) wrapping this script ensure that the +# whole script is loaded into memory before it is executed. As a result, issues +# are prevented when this script is formatted by itself. +{ + set -o errtrace + set -o pipefail + shopt -s globstar || exit + + #--- + # @stdout Help + print_help() { + cat <<-EOF + Format the project's files. + + Usage: + $script_name + $script_name -c + $script_name -h + + Options: + -c Check whether files are properly formatted. If issues are detected, a + non-zero exit status is used. Please note that no files are formatted + when this option is used. + -h Print help. + EOF + } + + script_name=$(basename -- "$0") + project_dir=$(cd -- "$(dirname -- "$0")/.." &>/dev/null && pwd) || exit + # shellcheck disable=SC2125 + GLOBIGNORE=$project_dir/.git/* + shebang_regex='^#!\s?/(usr/)?bin/(env\s+)?(sh|bash)$' + shfmt_opts=(-bn -ci) + + while getopts :ch option; do + case $option in + c) + check=1 + ;; + h) + print_help + exit + ;; + \?) + echo "Option is unknown: -$OPTARG" >&2 + exit 1 + ;; + esac + done + + paths=("$project_dir"/**/*) + for path in "${paths[@]}"; do + if [[ ! -r $path ]]; then + echo "Not readable: $path" >&2 + exit 1 + elif [[ ! -f $path ]]; then + continue # We can ignore directories that were matched by globstar. + elif [[ $path = *.sh ]]; then + shfmt_files+=("$path") + elif [[ ${path##*/} != *.* ]] \ + && read -r shebang <"$path" \ + && [[ $shebang =~ $shebang_regex ]]; then + if [[ ${BASH_REMATCH[3]} = bash || ${BASH_REMATCH[3]} = sh ]]; then + shfmt_files+=("$path") + fi + fi + done + + if [[ $check -eq 1 ]]; then + shfmt_opts+=(-d) + else + shfmt_opts+=(-w) + fi + + shfmt "${shfmt_opts[@]}" -- "${shfmt_files[@]}" + + exit +} diff --git a/sbin/lint b/sbin/lint new file mode 100755 index 0000000..ead0580 --- /dev/null +++ b/sbin/lint @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -o errtrace +set -o pipefail +shopt -s globstar || exit + +#--- +# @stdout Help +print_help() { + cat <<-EOF + Lint the project's files. + + Usage: + $script_name + $script_name -h + + Options: + -h Print help. + EOF +} + +script_name=$(basename -- "$0") +project_dir=$(cd -- "$(dirname -- "$0")/.." &>/dev/null && pwd) || exit +# shellcheck disable=SC2125 +GLOBIGNORE=$project_dir/.git/*:$project_dir/t/* +shebang_regex='^#!\s?/(usr/)?bin/(env\s+)?(sh|bash)$' + +while getopts :h option; do + case $option in + h) + print_help + exit + ;; + \?) + echo "Option is unknown: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +paths=("$project_dir"/**/*) +for path in "${paths[@]}"; do + if [[ ! -r $path ]]; then + echo "Not readable: $path" >&2 + exit 1 + elif [[ ! -f $path ]]; then + continue # We can ignore directories that were matched by globstar. + elif [[ $path = *.sh ]]; then + shellcheck_files+=("$path") + elif [[ ${path##*/} != *.* ]] \ + && read -r shebang <"$path" \ + && [[ $shebang =~ $shebang_regex ]]; then + if [[ ${BASH_REMATCH[3]} = bash || ${BASH_REMATCH[3]} = sh ]]; then + shellcheck_files+=("$path") + fi + fi +done + +shellcheck -- "${shellcheck_files[@]}" diff --git a/sbin/provision b/sbin/provision new file mode 100755 index 0000000..865cd48 --- /dev/null +++ b/sbin/provision @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# shellcheck source=/dev/null +test -f /etc/os-release && . "$_" +if [[ $ID != ubuntu || $VERSION_ID != 22.04 ]]; then + echo "Only Ubuntu 22.04 LTS (Jammy Jellyfish) is supported." >&2 + exit 1 +fi + +set -o errtrace +set -o pipefail + +trap '[[ $? -ne 0 && -s $output_log ]] && cat -- "$output_log" >&3' EXIT + +tmp_dir=$(mktemp --directory) || exit +output_log=$tmp_dir/output.log + +echo "Development environment is being provisioned..." + +# From now on, we will redirect any output to a logfile so that we can keep the +# output clean during provisioning. +exec 3>&2 >>"$output_log" 2>&1 + +cd -- "$tmp_dir" || exit + +sudo apt-get update || exit +sudo apt-get install --assume-yes shellcheck shfmt posh ksh diff --git a/sbin/test b/sbin/test new file mode 100755 index 0000000..a270d97 --- /dev/null +++ b/sbin/test @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -o errtrace +set -o pipefail + +#--- +# @stdout Help +print_help() { + cat <<-EOF + Run tests. + + Usage: + $script_name + $script_name -h + + Options: + -h Print help. + EOF +} + +#--- +# @param $1 Title of the section. +# @stdout Output that marks the start of the section. +start_section() { + local tty_fg_blue=$'\033[34m' + local tty_reset=$'\033[0m' + + echo + printf "%s" "$tty_fg_blue# $1 " + eval 'printf "#%.0s" {1..'"$((80 - ${#1} - 3))"'}' + printf "%s\n" "$tty_reset" + echo +} + +script_name=$(basename -- "$0") +project_dir=$(cd -- "$(dirname -- "$0")/.." &>/dev/null && pwd) || exit +shells=(posh dash ksh bash) +exit_status=0 + +while getopts :h option; do + case $option in + h) + print_help + exit + ;; + \?) + echo "Option is unknown: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +cd -- "$project_dir" || exit + +for shell in "${shells[@]}"; do + start_section "$shell" + prove --shuffle --ext sh --failures --comments --parse --exec "$shell" || exit_status=1 +done + +exit "$exit_status" diff --git a/t/abort.sh b/t/abort.sh new file mode 100644 index 0000000..faf88cc --- /dev/null +++ b/t/abort.sh @@ -0,0 +1,70 @@ +#!/bin/sh + +. ./test.sh + +test_call_during_file_setup() { + test_file=t/lib/abort_call_during_file_setup.sh + { expected_output=$(cat); } <<-EOF + Bail out! + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_setup() { + test_file=t/lib/abort_call_during_test_setup.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + Bail out! + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_without_reason_during_test() { + test_file=t/lib/abort_call_without_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + Bail out! + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_with_reason_during_test() { + test_file=t/lib/abort_call_with_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + Bail out! test + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_teardown() { + test_file=t/lib/abort_call_during_test_teardown.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # test_1 + Bail out! + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_file_teardown() { + test_file=t/lib/abort_call_during_file_teardown.sh + { expected_output=$(cat); } <<-EOF + Bail out! + 1..0 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +run_tests "$0" diff --git a/t/data_dir.sh b/t/data_dir.sh new file mode 100644 index 0000000..8fe16c3 --- /dev/null +++ b/t/data_dir.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +. ./test.sh + +test_creation() { + [ -d "$test_data_dir" ] || fail_test 1 "$test_data_dir" + output=$(ls -ld -- "$test_data_dir") || fail_test 2 + perms=${output%% *} + [ "$perms" = drwx------ ] || fail_test 3 "$perms" +} + +test_removal() { + test_file=t/lib/data_dir_removal.sh + ipc_file=$test_data_dir/removal.ipc + (. "$test_file" && run_tests "$test_file" >/dev/null) || fail_test 1 + data_dir=$(cat -- "$ipc_file") || fail_test 2 + [ ! -d "$data_dir" ] || fail_test 3 "$data_dir" +} + +run_tests "$0" diff --git a/t/fail.sh b/t/fail.sh new file mode 100644 index 0000000..85d01ab --- /dev/null +++ b/t/fail.sh @@ -0,0 +1,108 @@ +#!/bin/sh + +. ./test.sh + +test_missing_code_failure() { + output=$(fail_test 2>&1 || true) && return 1 + [ "$output" = "Code is required." ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_during_file_setup() { + test_file=t/lib/fail_call_during_file_setup.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # No test is currently running. + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_during_test_setup() { + test_file=t/lib/fail_call_during_test_setup.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # FAIL 1 + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_without_information_during_test() { + test_file=t/lib/fail_call_without_information_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # FAIL 1 + # --- + # test_1 + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_with_information_during_test() { + test_file=t/lib/fail_call_with_information_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # FAIL 1 + # --- + # test_1 + # error_1 + # error_2 + # error_3 + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_during_test_teardown() { + test_file=t/lib/fail_call_during_test_teardown.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # FAIL 1 + # --- + # test_1 + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +test_call_during_file_teardown() { + test_file=t/lib/fail_call_during_file_teardown.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # No test is currently running. + EOF + output=$(. "$test_file" && run_tests "$test_file") && return 1 + [ "$output" = "$expected_output" ] || { + printf "%s\n" "$output" + return 1 + } +} + +run_tests "$0" diff --git a/t/lib/abort_call_during_file_setup.sh b/t/lib/abort_call_during_file_setup.sh new file mode 100644 index 0000000..58e81ca --- /dev/null +++ b/t/lib/abort_call_during_file_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + abort_testing + printf "%s\n" setup_test_file +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/abort_call_during_file_teardown.sh b/t/lib/abort_call_during_file_teardown.sh new file mode 100644 index 0000000..b06b928 --- /dev/null +++ b/t/lib/abort_call_during_file_teardown.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +teardown_test_file() { + abort_testing + printf "%s\n" teardown_test_file +} diff --git a/t/lib/abort_call_during_test_setup.sh b/t/lib/abort_call_during_test_setup.sh new file mode 100644 index 0000000..0fedc79 --- /dev/null +++ b/t/lib/abort_call_during_test_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test() { + abort_testing + printf "%s\n" setup_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/abort_call_during_test_teardown.sh b/t/lib/abort_call_during_test_teardown.sh new file mode 100644 index 0000000..76101e9 --- /dev/null +++ b/t/lib/abort_call_during_test_teardown.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +teardown_test() { + abort_testing + printf "%s\n" teardown_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/abort_call_with_reason_during_test.sh b/t/lib/abort_call_with_reason_during_test.sh new file mode 100644 index 0000000..9e36ca3 --- /dev/null +++ b/t/lib/abort_call_with_reason_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + abort_testing test + printf "%s\n" test_1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/abort_call_without_reason_during_test.sh b/t/lib/abort_call_without_reason_during_test.sh new file mode 100644 index 0000000..751a56b --- /dev/null +++ b/t/lib/abort_call_without_reason_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + abort_testing + printf "%s\n" test_1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/data_dir_removal.sh b/t/lib/data_dir_removal.sh new file mode 100644 index 0000000..d8305d5 --- /dev/null +++ b/t/lib/data_dir_removal.sh @@ -0,0 +1,7 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + printf "%s\n" "$test_data_dir" >"$ipc_file" +} diff --git a/t/lib/fail_call_during_file_setup.sh b/t/lib/fail_call_during_file_setup.sh new file mode 100644 index 0000000..88c387b --- /dev/null +++ b/t/lib/fail_call_during_file_setup.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + fail_test 1 + printf "%s\n" setup_test_file +} diff --git a/t/lib/fail_call_during_file_teardown.sh b/t/lib/fail_call_during_file_teardown.sh new file mode 100644 index 0000000..5603381 --- /dev/null +++ b/t/lib/fail_call_during_file_teardown.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +teardown_test_file() { + fail_test 1 + printf "%s\n" teardown_test_file +} diff --git a/t/lib/fail_call_during_test_setup.sh b/t/lib/fail_call_during_test_setup.sh new file mode 100644 index 0000000..e661def --- /dev/null +++ b/t/lib/fail_call_during_test_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test() { + fail_test 1 + printf "%s\n" setup_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/fail_call_during_test_teardown.sh b/t/lib/fail_call_during_test_teardown.sh new file mode 100644 index 0000000..e1350ce --- /dev/null +++ b/t/lib/fail_call_during_test_teardown.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +teardown_test() { + fail_test 1 + printf "%s\n" teardown_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/fail_call_with_information_during_test.sh b/t/lib/fail_call_with_information_during_test.sh new file mode 100644 index 0000000..5977320 --- /dev/null +++ b/t/lib/fail_call_with_information_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + printf "%s\n" test_1 + fail_test 1 error_1 error_2 error_3 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/fail_call_without_information_during_test.sh b/t/lib/fail_call_without_information_during_test.sh new file mode 100644 index 0000000..759742f --- /dev/null +++ b/t/lib/fail_call_without_information_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + printf "%s\n" test_1 + fail_test 1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/run_call.sh b/t/lib/run_call.sh new file mode 100644 index 0000000..65673c2 --- /dev/null +++ b/t/lib/run_call.sh @@ -0,0 +1,39 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + printf "%s\n" setup_test_file +} + +teardown_test_file() { + printf "%s\n" teardown_test_file +} + +setup_test() { + printf "%s\n" setup_test +} + +teardown_test() { + printf "%s\n" teardown_test +} + +test_1() { + printf "%s\n" "$test_func" + exit +} + +test_2() { + printf "%s\n" "$test_func" + printf "%s\n" error >&2 + return 1 +} + +test_3() { + printf "%s\n" "$test_func" +} + +test_4() { + kill "$(exec sh -c 'printf "%s" "$PPID"')" + printf "%s\n" "$test_func" +} diff --git a/t/lib/setup_file_invocation.sh b/t/lib/setup_file_invocation.sh new file mode 100644 index 0000000..ce0d5ee --- /dev/null +++ b/t/lib/setup_file_invocation.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + printf "%s\n" setup_test_file + return 1 +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/setup_test_invocation.sh b/t/lib/setup_test_invocation.sh new file mode 100644 index 0000000..33a095b --- /dev/null +++ b/t/lib/setup_test_invocation.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test() { + printf "%s\n" setup_test + return 1 +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/skip_call_during_file_teardown.sh b/t/lib/skip_call_during_file_teardown.sh new file mode 100644 index 0000000..8de16f3 --- /dev/null +++ b/t/lib/skip_call_during_file_teardown.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +teardown_test_file() { + skip_test + printf "%s\n" teardown_test_file +} diff --git a/t/lib/skip_call_during_test_setup.sh b/t/lib/skip_call_during_test_setup.sh new file mode 100644 index 0000000..3f74953 --- /dev/null +++ b/t/lib/skip_call_during_test_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test() { + skip_test + printf "%s\n" setup_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/skip_call_during_test_teardown.sh b/t/lib/skip_call_during_test_teardown.sh new file mode 100644 index 0000000..69ce81b --- /dev/null +++ b/t/lib/skip_call_during_test_teardown.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +teardown_test() { + skip_test + printf "%s\n" teardown_test +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/skip_call_with_reason_during_file_setup.sh b/t/lib/skip_call_with_reason_during_file_setup.sh new file mode 100644 index 0000000..0d8cd95 --- /dev/null +++ b/t/lib/skip_call_with_reason_during_file_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + skip_test test + printf "%s\n" setup_test_file +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/skip_call_with_reason_during_test.sh b/t/lib/skip_call_with_reason_during_test.sh new file mode 100644 index 0000000..ed7d009 --- /dev/null +++ b/t/lib/skip_call_with_reason_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + skip_test test + printf "%s\n" test_1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/skip_call_without_reason_during_file_setup.sh b/t/lib/skip_call_without_reason_during_file_setup.sh new file mode 100644 index 0000000..c080c1c --- /dev/null +++ b/t/lib/skip_call_without_reason_during_file_setup.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + skip_test + printf "%s\n" setup_test_file +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/skip_call_without_reason_during_test.sh b/t/lib/skip_call_without_reason_during_test.sh new file mode 100644 index 0000000..bc872d4 --- /dev/null +++ b/t/lib/skip_call_without_reason_during_test.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +test_1() { + skip_test + printf "%s\n" test_1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/teardown_file_invocation.sh b/t/lib/teardown_file_invocation.sh new file mode 100644 index 0000000..3bc2991 --- /dev/null +++ b/t/lib/teardown_file_invocation.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +teardown_test_file() { + printf "%s\n" teardown_test_file + exit 1 +} diff --git a/t/lib/teardown_test_invocation.sh b/t/lib/teardown_test_invocation.sh new file mode 100644 index 0000000..eb3673a --- /dev/null +++ b/t/lib/teardown_test_invocation.sh @@ -0,0 +1,12 @@ +_testsh_fork + +. ./test.sh + +teardown_test() { + printf "%s\n" teardown_test + exit 1 +} + +test_1() { + printf "%s\n" test_1 +} diff --git a/t/lib/todo_call_during_file_setup.sh b/t/lib/todo_call_during_file_setup.sh new file mode 100644 index 0000000..0cbfb3e --- /dev/null +++ b/t/lib/todo_call_during_file_setup.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +setup_test_file() { + mark_test_todo + printf "%s\n" setup_test_file +} diff --git a/t/lib/todo_call_during_file_teardown.sh b/t/lib/todo_call_during_file_teardown.sh new file mode 100644 index 0000000..1193b27 --- /dev/null +++ b/t/lib/todo_call_during_file_teardown.sh @@ -0,0 +1,8 @@ +_testsh_fork + +. ./test.sh + +teardown_test_file() { + mark_test_todo + printf "%s\n" teardown_test_file +} diff --git a/t/lib/todo_call_during_test_setup.sh b/t/lib/todo_call_during_test_setup.sh new file mode 100644 index 0000000..ddfeb45 --- /dev/null +++ b/t/lib/todo_call_during_test_setup.sh @@ -0,0 +1,13 @@ +_testsh_fork + +. ./test.sh + +setup_test() { + mark_test_todo + printf "%s\n" setup_test +} + +test_1() { + printf "%s\n" test_1 + return 1 +} diff --git a/t/lib/todo_call_during_test_teardown.sh b/t/lib/todo_call_during_test_teardown.sh new file mode 100644 index 0000000..b35115d --- /dev/null +++ b/t/lib/todo_call_during_test_teardown.sh @@ -0,0 +1,13 @@ +_testsh_fork + +. ./test.sh + +teardown_test() { + mark_test_todo + printf "%s\n" teardown_test +} + +test_1() { + printf "%s\n" test_1 + return 1 +} diff --git a/t/lib/todo_call_with_reason_during_test.sh b/t/lib/todo_call_with_reason_during_test.sh new file mode 100644 index 0000000..2afb24d --- /dev/null +++ b/t/lib/todo_call_with_reason_during_test.sh @@ -0,0 +1,13 @@ +_testsh_fork + +. ./test.sh + +test_1() { + mark_test_todo test + printf "%s\n" test_1 + return 1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/lib/todo_call_without_reason_during_test.sh b/t/lib/todo_call_without_reason_during_test.sh new file mode 100644 index 0000000..b60313b --- /dev/null +++ b/t/lib/todo_call_without_reason_during_test.sh @@ -0,0 +1,13 @@ +_testsh_fork + +. ./test.sh + +test_1() { + mark_test_todo + printf "%s\n" test_1 + return 1 +} + +test_2() { + printf "%s\n" test_2 +} diff --git a/t/run.sh b/t/run.sh new file mode 100644 index 0000000..191092b --- /dev/null +++ b/t/run.sh @@ -0,0 +1,61 @@ +#!/bin/sh + +. ./test.sh + +exit_status=0 + +test_num=1 +test_name=test_missing_path_failure +if output=$(run_tests 2>&1 || true); then + printf "%s\n" "not ok $test_num $test_name" + exit_status=1 +elif [ "$output" != "Path to the test file is required." ]; then + printf "%s\n" "not ok $test_num $test_name" + _testsh_printf "# %s\n" <<-EOF + $output + EOF + exit_status=1 +else + printf "%s\n" "ok $test_num $test_name" +fi + +test_num=2 +test_name=test_call +test_file=t/lib/run_call.sh +{ expected_output=$(cat); } <<-EOF + # setup_test_file + ok 1 test_1 + # setup_test + # test_1 + # teardown_test + not ok 2 test_2 + # setup_test + # test_2 + # error + # teardown_test + ok 3 test_3 + # setup_test + # test_3 + # teardown_test + not ok 4 test_4 + # setup_test + # teardown_test + 1..4 + # teardown_test_file +EOF +if output=$(exec 2>&1 && . "$test_file" && run_tests "$test_file"); then + printf "%s\n" "not ok $test_num $test_name" + exit_status=1 +elif [ "$output" != "$expected_output" ]; then + printf "%s\n" "not ok $test_num $test_name" + _testsh_printf "# %s\n" <<-EOF + $output + EOF + exit_status=1 +else + printf "%s\n" "ok $test_num $test_name" +fi + +printf "%s\n" "1..2" + +exit "$exit_status" diff --git a/t/setup.sh b/t/setup.sh new file mode 100644 index 0000000..a5798f6 --- /dev/null +++ b/t/setup.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +. ./test.sh + +test_file_invocation() { + test_file=t/lib/setup_file_invocation.sh + { expected_output=$(cat); } <<-EOF + 1..1 + # setup_test_file + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_test_invocation() { + test_file=t/lib/setup_test_invocation.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # setup_test + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +run_tests "$0" diff --git a/t/skip.sh b/t/skip.sh new file mode 100644 index 0000000..34b4928 --- /dev/null +++ b/t/skip.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +. ./test.sh + +test_call_without_reason_during_file_setup() { + test_file=t/lib/skip_call_without_reason_during_file_setup.sh + { expected_output=$(cat); } <<-EOF + 1..0 # SKIP + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_with_reason_during_file_setup() { + test_file=t/lib/skip_call_with_reason_during_file_setup.sh + { expected_output=$(cat); } <<-EOF + 1..0 # SKIP test + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_setup() { + test_file=t/lib/skip_call_during_test_setup.sh + { expected_output=$(cat); } <<-EOF + ok 1 test_1 # SKIP + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_without_reason_during_test() { + test_file=t/lib/skip_call_without_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + ok 1 test_1 # SKIP + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_with_reason_during_test() { + test_file=t/lib/skip_call_with_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + ok 1 test_1 # SKIP test + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_teardown() { + test_file=t/lib/skip_call_during_test_teardown.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # test_1 + # Test(s) cannot be skipped anymore. + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_file_teardown() { + test_file=t/lib/skip_call_during_file_teardown.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # Test(s) cannot be skipped anymore. + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +run_tests "$0" diff --git a/t/teardown.sh b/t/teardown.sh new file mode 100644 index 0000000..5da1594 --- /dev/null +++ b/t/teardown.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +. ./test.sh + +test_test_invocation() { + test_file=t/lib/teardown_test_invocation.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 + # test_1 + # teardown_test + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_file_invocation() { + test_file=t/lib/teardown_file_invocation.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # teardown_test_file + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +run_tests "$0" diff --git a/t/todo.sh b/t/todo.sh new file mode 100644 index 0000000..d800727 --- /dev/null +++ b/t/todo.sh @@ -0,0 +1,75 @@ +#!/bin/sh + +. ./test.sh + +test_call_during_file_setup() { + test_file=t/lib/todo_call_during_file_setup.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # No test is currently running. + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_setup() { + test_file=t/lib/todo_call_during_test_setup.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 # TODO + # setup_test + # test_1 + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_without_reason_during_test() { + test_file=t/lib/todo_call_without_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 # TODO + # test_1 + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_with_reason_during_test() { + test_file=t/lib/todo_call_with_reason_during_test.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 # TODO test + # test_1 + ok 2 test_2 + # test_2 + 1..2 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_test_teardown() { + test_file=t/lib/todo_call_during_test_teardown.sh + { expected_output=$(cat); } <<-EOF + not ok 1 test_1 # TODO + # test_1 + # teardown_test + 1..1 + EOF + output=$(. "$test_file" && run_tests "$test_file") || fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +test_call_during_file_teardown() { + test_file=t/lib/todo_call_during_file_teardown.sh + { expected_output=$(cat); } <<-EOF + 1..0 + # No test is currently running. + EOF + output=$(. "$test_file" && run_tests "$test_file") && fail_test 1 + [ "$output" = "$expected_output" ] || fail_test 2 "$output" +} + +run_tests "$0" diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..b5121a6 --- /dev/null +++ b/test.sh @@ -0,0 +1,450 @@ +# shellcheck shell=sh + +# test.sh +# https://github.com/TobyGiacometti/test.sh +# Copyright (c) 2022 Toby Giacometti and contributors +# Apache License 2.0 + +#--- +# Run all tests in the current test file. +# +# Any test file function whose name starts with `test` represents a single test. +# Each test function runs in a separate subshell and any return code other than +# 0 is interpreted as a test failure. +# +# Other special functions can be defined in test files to execute actions during +# testing: +# +# - `setup_test_file()`: Invoked before tests in a test file run. Any return code +# other than 0 skips all tests in the test file. +# - `teardown_test_file()`: Invoked before a test file exits. +# - `setup_test()`: Invoked before each test runs. Any return code other than 0 +# skips the test and marks it as failed. +# - `teardown_test()`: Invoked before a test subshell exits. Any return code other +# than 0 marks the test as failed. +# +# A typical test file looks as follows: +# +# ```sh +# #!/bin/sh +# +# . /path/to/test.sh +# +# setup_test_file() { # optional +# # test file setup code +# } +# +# teardown_test_file() { # optional +# # test file teardown code +# } +# +# setup_test() { # optional +# # test setup code +# } +# +# teardown_test() { # optional +# # test teardown code +# } +# +# test_feature_one() { +# # test code +# } +# +# test_feature_two() { +# # test code +# } +# +# run_tests "$0" +# ``` +# +# @param $1 Path to the test file this function is called from. This parameter +# is required since test.sh can't use `$0` internally (value of `$0` +# in sourced scripts is unspecified in the POSIX standard). +# @stdout [TAP](https://testanything.org/tap-specification.html) +# @exit +run_tests() { + if [ -z "$1" ]; then + printf "%s\n" "Path to the test file is required." >&2 + exit 1 + fi + + _testsh_test_funcs=$( + sed -n \ + 's/^[[:space:]]*\(test[A-Za-z0-9_][A-Za-z0-9_]*\)[[:space:]]*().*/\1/p' \ + -- "$1" + ) || exit + + # First create the private directory in case the public directory is stored + # within. As a result, the operation is more secure since we don't have to + # use the `-p` option. + mkdir -m 700 -- "$_testsh_data_dir" "$test_data_dir" || exit + + _testsh_register_exit_handler _testsh_handle_file_exit + + # Since we don't need valid TAP output during a trace, output recording is + # not enabled if the xtrace option is set. As a result, infinite loops are + # prevented in some circumstances in which the output buffer file is being + # used as STDIN. + case $- in + *x*) + exec 3>&1 + : >"$_testsh_data_dir/output" || exit + ;; + *) + exec 3>&1 >>"$_testsh_data_dir/output" 2>&1 || exit + ;; + esac + + _testsh_scope=setup_file setup_test_file || exit + + set 0 + if [ -n "$_testsh_test_funcs" ]; then + _testsh_printf "# %s\n" <"$_testsh_data_dir/output" >&3 + : >"$_testsh_data_dir/output" + + while IFS= read -r test_func; do + # STDIN is connected to /dev/null so that the test function does not + # receive the test function name when reading STDIN. + _testsh_run_test "$test_func" "$_testsh_data_dir/$_testsh_test_num.todo" + ;; + *) + printf "%s\n" "No test is currently running." >&2 + exit 1 + ;; + esac +} + +#--- +# Skip the current test or all tests in the current test file. +# +# Call this function inside `setup_test_file()` to skip all tests in the current +# test file. +# +# Note: This function calls `exit`. If called inside a subshell, the `exit` call +# needs to be propagated. +# +# @param $1 Reason why the current test or all tests in the current test file are +# being skipped (optional). +# @exit +skip_test() { + case $_testsh_scope in + setup_file) + printf "%s" "$1" >"$_testsh_data_dir/skip" + exit + ;; + setup_test | test) + printf "%s" "$1" >"$_testsh_data_dir/$_testsh_test_num.skip" + exit + ;; + *) + printf "%s\n" "Test(s) cannot be skipped anymore." >&2 + exit 1 + ;; + esac +} + +#--- +# Abort the testing process. +# +# Note: This function calls `exit`. If called inside a subshell, the `exit` call +# needs to be propagated. +# +# @param $1 Reason why the testing process is being aborted (optional). +# @exit +abort_testing() { + printf "%s" "$1" >"$_testsh_data_dir/bail" + exit 1 +} + +#--- +# Fail the current test. +# +# While `return 1` or `exit 1` also fail a test, calling this function is often +# more convenient because it automatically generates well formatted diagnostic +# output that helps to identify what caused the fail. +# +# Note: This function calls `exit`. If called inside a subshell, the `exit` call +# needs to be propagated. +# +# @param $1 Code that helps to identify what caused the fail. +# @param... Additional information (optional). Each parameter's value is printed +# on its own line. +# @stdout Diagnostic information +# @exit +fail_test() { + if [ -z "$1" ]; then + printf "%s\n" "Code is required." >&2 + exit 1 + elif [ "$_testsh_scope" != setup_test ] \ + && [ "$_testsh_scope" != test ] \ + && [ "$_testsh_scope" != teardown_test ]; then + printf "%s\n" "No test is currently running." >&2 + exit 1 + fi + + cp -- "$_testsh_data_dir/output" "$_testsh_data_dir/output.fail" + : >"$_testsh_data_dir/output" + + printf "%s\n" "FAIL $1" + shift 1 + if [ -s "$_testsh_data_dir/output.fail" ] || [ "$#" -gt 0 ]; then + printf "%s\n" --- + cat -- "$_testsh_data_dir/output.fail" + [ "$#" -gt 0 ] && printf "%s\n" "$@" + fi + + exit 1 +} + +# There's no way to check whether a function has been defined in shells that use +# an older version of the POSIX standard without the User Portability Utilities +# option. Due to this, we define no-op functions here in case the user does not +# define them. +setup_test_file() { :; } +teardown_test_file() { :; } +setup_test() { :; } +teardown_test() { :; } + +#--- +# @param $1 Name of the test function that should be invoked. +# @fd 3 [TAP](https://testanything.org/tap-specification.html) +# @internal +_testsh_run_test() { + if [ -z "$1" ]; then + printf "%s\n" "Name of the test function is required." >&2 + exit 1 + fi + + _testsh_test_num=$((_testsh_test_num + 1)) + + ( + _testsh_fork + _testsh_register_exit_handler _testsh_handle_test_exit + _testsh_scope=setup_test setup_test || exit 1 + # shellcheck disable=SC2209 + _testsh_scope=test "$1" + ) +} + +#--- +# @param $1 Name of the exit handler (function). The exit handler receives `sig` +# as the first argument if invoked due to a signal or an empty string +# otherwise. The second parameter contains the status code of the last +# command that was executed. Please note that `exit` calls inside the +# exit handler should only be made to override the exit status. +# @internal +_testsh_register_exit_handler() { + if ! _testsh_is_posix_name "$1"; then + printf "%s\n" "Name of the exit handler is invalid." >&2 + exit 1 + fi + + _testsh_handle_exit() { + # Execute the handler only once. + trap '' INT TERM EXIT + # Exit status needs to be specified explicitly otherwise the script exits + # with the status of the command that was executed before the trap. + "$@" || exit "$?" + # We need to call exit explicitly on signal otherwise some shells don't + # exit. In addition, we always want a nonzero exit status on signals. + [ "$2" != sig ] || exit 1 + } + + trap '_testsh_handle_exit '"$1"' "" "$?"' EXIT + # Some shells ignore the exit trap on signals. + trap '_testsh_handle_exit '"$1"' sig "$?"' INT TERM +} + +#--- +# @param $@ Check the documentation of `_testsh_register_exit_handler()`. +# @fd 3 [TAP](https://testanything.org/tap-specification.html) +# @internal +_testsh_handle_file_exit() { + # Use a subshell to catch `exit` calls so that we can continue final tasks. + (_testsh_scope=teardown_file teardown_test_file) + set "$?" + + if [ -e "$_testsh_data_dir/bail" ]; then + printf "%s" "Bail out!" >&3 + _testsh_printf " %s" <"$_testsh_data_dir/bail" >&3 + printf "\n" >&3 + set 1 + fi + + printf "%s" "1.." >&3 + if [ -e "$_testsh_data_dir/skip" ]; then + printf "%s" "0 # SKIP" >&3 + _testsh_printf " %s" <"$_testsh_data_dir/skip" >&3 + elif [ -z "$_testsh_test_funcs" ]; then + printf "%s" 0 >&3 + else + printf "%s\n" "$_testsh_test_funcs" | wc -l | tr -d '[:space:]' >&3 + fi + printf "\n" >&3 + + _testsh_printf "# %s\n" <"$_testsh_data_dir/output" >&3 + + # Users should be notified if files/directories inside test data directories + # cannot be removed. + rm -rf -- "$test_data_dir" "$_testsh_data_dir" 2>&1 | _testsh_printf "# %s\n" >&3 + + return "$1" +} + +#--- +# @param $@ Check the documentation of `_testsh_register_exit_handler()`. +# @fd 3 [TAP](https://testanything.org/tap-specification.html) +# @exit +# @internal +_testsh_handle_test_exit() { + # Use a subshell to catch `exit` calls so that we can continue final tasks. + (_testsh_scope=teardown_test teardown_test) || set -- "$1" 1 + + if [ "$1" != sig ] && [ "$2" -eq 0 ]; then + printf "%s" ok >&3 + else + printf "%s" "not ok" >&3 + set -- "$1" 1 + fi + printf "%s" " $_testsh_test_num $test_func" >&3 + + if [ -e "$_testsh_data_dir/$_testsh_test_num.skip" ]; then + printf "%s" " # SKIP" >&3 + _testsh_printf " %s" <"$_testsh_data_dir/$_testsh_test_num.skip" >&3 + elif [ -e "$_testsh_data_dir/$_testsh_test_num.todo" ]; then + printf "%s" " # TODO" >&3 + _testsh_printf " %s" <"$_testsh_data_dir/$_testsh_test_num.todo" >&3 + # Tests that fail but are marked as todo should not lead to a nonzero + # exit status. + set -- "$1" 0 + fi + + printf "\n" >&3 + + _testsh_printf "# %s\n" <"$_testsh_data_dir/output" >&3 + : >"$_testsh_data_dir/output" + + # Since we are inside an exit trap, we exit with an explicit status code so + # that we can override the exit status for tests that are marked as todo. + exit "$2" +} + +#--- +# Write formatted output. +# +# This function is less optimized than external utilities that are specifically +# designed for text processing. However, since we don't usually process a lot of +# text, this function ends up being faster. In addition, it is more convenient +# for our usecase. +# +# @param $1 Format string for `printf`. Applied to each line of text. +# @stdin Text that should be formatted. +# @stdout Formatted text +# @internal +_testsh_printf() { + if [ -z "$1" ]; then + printf "%s\n" "Format string is required." >&2 + exit 1 + fi + + # We also process the last line if it does not end with a newline character. + while IFS= read -r _testsh_line || [ -n "$_testsh_line" ]; do + # shellcheck disable=SC2059 + printf -- "$1" "$_testsh_line" || exit "$?" # Exit traps need explicit status. + done + + unset -v _testsh_line +} + +#--- +# Fork a new process. +# +# Some shells don't fork a new process for subshells even though it is required. +# This function forks a new process in those shells. +# +# @internal +_testsh_fork() { + # shellcheck disable=SC3045 + [ -z "$KSH_VERSION" ] || ulimit -t unlimited +} + +#--- +# Check whether a value is a POSIX name. +# +# +# +# @param $1 Value that should be checked. +# @internal +_testsh_is_posix_name() { + case $1 in + "") + return 1 + ;; + [0-9]*) + return 1 + ;; + *[!a-zA-Z0-9_]*) + return 1 + ;; + esac +} + +#--- +# @var Path to a directory that can be used to store test data. The directory is +# created when `run_tests()` is called and removed once testing completes. +# +# shellcheck disable=SC2016 +test_data_dir=${TMPDIR:-/tmp}/test$(exec sh -c '[ -n "$PPID" ] && printf "%s" "$PPID" && awk "BEGIN { srand (); print int(rand()*100000) }"')/public || exit +#--- +# @var Name of the current test function. Populated shortly before `setup_test()` +# invocation. +test_func= +#--- +# @var Name of the current scope: +# +# - `file` +# - `setup_file` +# - `setup_test` (only inside a test subshell) +# - `test` (only inside a test subshell) +# - `teardown_test` (only inside a test subshell) +# - `teardown_file` +# +# @internal +# +# shellcheck disable=SC2209 +_testsh_scope=file +#--- +# @var Names of test functions in the current test file. Each name is separated +# by a newline. This variable is populated after `run_tests()` is called. +# @internal +_testsh_test_funcs= +#--- +# @var Number of the current test. +# @internal +_testsh_test_num=0 +#--- +# @var Path to a directory that can be used internally by test.sh to store test +# data. The directory is created when `run_tests()` is called and removed +# once testing completes. +# @internal +_testsh_data_dir=${test_data_dir%/*}