The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.

NAME

Test::NoTty

SYNOPSIS

    without_tty(sub {
        open my $fh, '+<', '/dev/tty'
            or die "Test this code path, even when run interactively";
        ...
    });

DESCRIPTION

Test your code that handles failure to open /dev/tty

On a *nix system the special file /dev/tty always exists, and opening it gives you a(nother) file handle attached to your controlling terminal. This is useful if you want direct user input, such as entering passwords or passphrases, even if STDIN or STDOUT are redirected.

But what happens if your code is running non-interactively? Such as servers, cron jobs, or just CPAN testers? /dev/tty still exists, but opening it will fail. Your tests need to cover this case. But how do you test your tests as you write them, when you're running them in a terminal session?

That's the purpose of this module. It encapsulates the complex setup dance with fork, setsid etc, to locally drop the controlling terminal, so that you can interactively run code to test those code paths.

SUBROUTINES

The module provides a single function and is intended for test scripts, so exports that function by default

without_tty sub arguments ...

without_tty calls the passed-in subroutine with the optional list of arguments, but in an environment without a controlling terminal, and hence where attempting to open the character device file /dev/tty will fail.

(With caveats) it returns the result of the passed-in subroutine, or any exception thrown.

without_tty has the prototype &@ to permit it to take a bare block like this:

    without_tty {
        open my $fh, '+<', '/dev/tty'
            or die "Test this path!";
        ...
    }

This is similar to other testing helper functions, such as exception in Test::Fatal.

To drop the controlling terminal, the code needs to fork a child process and then perform some system calls. Hence the subroutine is run in a forked child, meaning

  • Side effects "don't happen" in the parent - writes to structures passed in as references get discarded, as do changes to global state. This is both for your code and any code it calls.

  • You can only return a single integer (it's the child process exit code)

  • Exceptions are always strings - any objects get stringified

This is restrictive, but it's this is about as good it's possible to get. The code absolutely has to run in a forked child to be able to drop the controlling terminal - everything else you get has to be rebuilt by some other emulation or cheating.

without_tty throws an exception if the child process dies with a signal. (Don't write code that relies on this to trap signals - this is error handling)

The code attempts to propagate signals to the child, so that control-C, control-Z and similar work somewhat as expected when tests are run interactively, but this also is "best effort" and more intended as "fail less ungracefully" than "rely on this and report bugs if it fails".

It means that you're using Test::More and run tests (is, like, etc) in your subroutine, the test counter doesn't update "outside" in the parent, and your test script will fail. The simple solution to this - update to

    use Test2::Bundle::More;
    use Test2::IPC;

and be happy. These modules have shipped with core since v5.26.0 and most CPAN distributions already indirectly depend on them, so likely you already have it installed and available even if you have to target unsupported Perl versions.

RATIONALE

At work our use case is testing our database code. Its configuration has connection parameters (database, username, password, etc). We'd like to be able to run the same code

  • In production, which will always need a password from the configuration

  • On a development system, running tests, using the local database password

  • On a development system, diagnosing problems, connecting to a read-only mirror, when the self-same code prompts us for the password (kept in a secure password store)

For this, we need a configuration that is capable of meaning "prompt the user for the password", and we have implemented this by having a password of undef. For that case we open /dev/tty, turn of echoing, and read in the password (just like ssh clients, database CLI tools, etc)

That's fine in development, but it would be easy to make a mistake in the production configuration and accidentally hit the same code path. We want the code to fail early and clearly, we need to write tests for that, and we need to test those tests interactively. Hence we want a way to "run this block of code as if it's non-interactive". Hence this module.

To keep things as simple as possible, the work code is structured to have just the prompt code in a private method:

    sub _prompt {
        my ($log, $fail_no_tty, $prompt_message) = @_;
        confess('No parameters to _prompt may be empty')
            unless $log and length $fail_no_tty and length $prompt_message;

        my $dev_tty = '/dev/tty';
        my $tty_fh;
        unless (open $tty_fh, '+<', $dev_tty) {
            ... # error code (which dies)
        }
        ... # prompt code
        return $password;
    }

and that is all that we test with this module - all the rest is regular regression tests.

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself. If you would like to contribute documentation, features, bug fixes, or anything else then please raise an issue / pull request:

    https://github.com/Humanstate/test-notty

AUTHOR

Nicholas Clark - nick@ccl4.org