summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/asciicast/__init__.py0
-rw-r--r--tests/asciicast/v2_test.py28
-rw-r--r--tests/config_test.py218
-rw-r--r--tests/demo.cast40
-rw-r--r--tests/demo.json114
-rwxr-xr-xtests/distros.sh38
-rw-r--r--tests/distros/Dockerfile.alpine19
-rw-r--r--tests/distros/Dockerfile.arch22
-rw-r--r--tests/distros/Dockerfile.centos18
-rw-r--r--tests/distros/Dockerfile.debian33
-rw-r--r--tests/distros/Dockerfile.fedora20
-rw-r--r--tests/distros/Dockerfile.ubuntu32
-rwxr-xr-xtests/integration.sh95
-rw-r--r--tests/pty_test.py54
-rw-r--r--tests/test_helper.py16
16 files changed, 747 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/asciicast/__init__.py b/tests/asciicast/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/asciicast/__init__.py
diff --git a/tests/asciicast/v2_test.py b/tests/asciicast/v2_test.py
new file mode 100644
index 0000000..113ddf7
--- /dev/null
+++ b/tests/asciicast/v2_test.py
@@ -0,0 +1,28 @@
+import json
+import tempfile
+
+from asciinema.asciicast import v2
+
+from ..test_helper import Test
+
+
+class TestWriter(Test):
+ @staticmethod
+ def test_writing() -> None:
+ _file, path = tempfile.mkstemp()
+
+ with v2.writer(path, width=80, height=24) as w:
+ w.write_stdout(1, "x") # ensure it supports both str and bytes
+ w.write_stdout(2, bytes.fromhex("78 c5 bc c3 b3 c5"))
+ w.write_stdout(3, bytes.fromhex("82 c4 87"))
+ w.write_stdout(4, bytes.fromhex("78 78"))
+
+ with open(path, "rt", encoding="utf_8") as f:
+ lines = list(map(json.loads, f.read().strip().split("\n")))
+ assert lines == [
+ {"version": 2, "width": 80, "height": 24},
+ [1, "o", "x"],
+ [2, "o", "xżó"],
+ [3, "o", "łć"],
+ [4, "o", "xx"],
+ ], f"got:\n\n{lines}"
diff --git a/tests/config_test.py b/tests/config_test.py
new file mode 100644
index 0000000..7b154ff
--- /dev/null
+++ b/tests/config_test.py
@@ -0,0 +1,218 @@
+import re
+import tempfile
+from os import path
+from typing import Dict, Optional
+
+import asciinema.config as cfg
+from asciinema.config import Config
+
+
+def create_config(
+ content: Optional[str] = None, env: Optional[Dict[str, str]] = None
+) -> Config:
+ # avoid redefining `dir` builtin
+ dir_ = tempfile.mkdtemp()
+
+ if content:
+ # avoid redefining `os.path`
+ path_ = f"{dir_}/config"
+ with open(path_, "wt", encoding="utf_8") as f:
+ f.write(content)
+
+ return cfg.Config(dir_, env)
+
+
+def read_install_id(install_id_path: str) -> str:
+ with open(install_id_path, "rt", encoding="utf_8") as f:
+ return f.read().strip()
+
+
+def test_upgrade_no_config_file() -> None:
+ config = create_config()
+ config.upgrade()
+ install_id = read_install_id(config.install_id_path)
+
+ assert re.match("^\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}", install_id)
+ assert install_id == config.install_id
+ assert not path.exists(config.config_file_path)
+
+ # it must not change after another upgrade
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == install_id
+
+
+def test_upgrade_config_file_with_api_token() -> None:
+ config = create_config("[api]\ntoken = foo-bar-baz")
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+ assert config.install_id == "foo-bar-baz"
+ assert not path.exists(config.config_file_path)
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_api_token_and_more() -> None:
+ config = create_config(
+ "[api]\ntoken = foo-bar-baz\nurl = http://example.com"
+ )
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+ assert config.install_id == "foo-bar-baz"
+ assert config.api_url == "http://example.com"
+ assert path.exists(config.config_file_path)
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_user_token() -> None:
+ config = create_config("[user]\ntoken = foo-bar-baz")
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+ assert config.install_id == "foo-bar-baz"
+ assert not path.exists(config.config_file_path)
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_upgrade_config_file_with_user_token_and_more() -> None:
+ config = create_config(
+ "[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com"
+ )
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+ assert config.install_id == "foo-bar-baz"
+ assert config.api_url == "http://example.com"
+ assert path.exists(config.config_file_path)
+
+ config.upgrade()
+
+ assert read_install_id(config.install_id_path) == "foo-bar-baz"
+
+
+def test_default_api_url() -> None:
+ config = create_config("")
+ assert config.api_url == "https://asciinema.org"
+
+
+def test_default_record_stdin() -> None:
+ config = create_config("")
+ assert config.record_stdin is False
+
+
+def test_default_record_command() -> None:
+ config = create_config("")
+ assert config.record_command is None
+
+
+def test_default_record_env() -> None:
+ config = create_config("")
+ assert config.record_env == "SHELL,TERM"
+
+
+def test_default_record_idle_time_limit() -> None:
+ config = create_config("")
+ assert config.record_idle_time_limit is None
+
+
+def test_default_record_yes() -> None:
+ config = create_config("")
+ assert config.record_yes is False
+
+
+def test_default_record_quiet() -> None:
+ config = create_config("")
+ assert config.record_quiet is False
+
+
+def test_default_play_idle_time_limit() -> None:
+ config = create_config("")
+ assert config.play_idle_time_limit is None
+
+
+def test_api_url() -> None:
+ config = create_config("[api]\nurl = http://the/url")
+ assert config.api_url == "http://the/url"
+
+
+def test_api_url_when_override_set() -> None:
+ config = create_config(
+ "[api]\nurl = http://the/url", {"ASCIINEMA_API_URL": "http://the/url2"}
+ )
+ assert config.api_url == "http://the/url2"
+
+
+def test_record_command() -> None:
+ command = "bash -l"
+ config = create_config(f"[record]\ncommand = {command}")
+ assert config.record_command == command
+
+
+def test_record_stdin() -> None:
+ config = create_config("[record]\nstdin = yes")
+ assert config.record_stdin is True
+
+
+def test_record_env() -> None:
+ config = create_config("[record]\nenv = FOO,BAR")
+ assert config.record_env == "FOO,BAR"
+
+
+def test_record_idle_time_limit() -> None:
+ config = create_config("[record]\nidle_time_limit = 2.35")
+ assert config.record_idle_time_limit == 2.35
+
+ config = create_config("[record]\nmaxwait = 2.35")
+ assert config.record_idle_time_limit == 2.35
+
+
+def test_record_yes() -> None:
+ yes = "yes"
+ config = create_config(f"[record]\nyes = {yes}")
+ assert config.record_yes is True
+
+
+def test_record_quiet() -> None:
+ quiet = "yes"
+ config = create_config(f"[record]\nquiet = {quiet}")
+ assert config.record_quiet is True
+
+
+def test_play_idle_time_limit() -> None:
+ config = create_config("[play]\nidle_time_limit = 2.35")
+ assert config.play_idle_time_limit == 2.35
+
+ config = create_config("[play]\nmaxwait = 2.35")
+ assert config.play_idle_time_limit == 2.35
+
+
+def test_notifications_enabled() -> None:
+ config = create_config("")
+ assert config.notifications_enabled is True
+
+ config = create_config("[notifications]\nenabled = yes")
+ assert config.notifications_enabled is True
+
+ config = create_config("[notifications]\nenabled = no")
+ assert config.notifications_enabled is False
+
+
+def test_notifications_command() -> None:
+ config = create_config("")
+ assert config.notifications_command is None
+
+ config = create_config(
+ '[notifications]\ncommand = tmux display-message "$TEXT"'
+ )
+ assert config.notifications_command == 'tmux display-message "$TEXT"'
diff --git a/tests/demo.cast b/tests/demo.cast
new file mode 100644
index 0000000..fe55360
--- /dev/null
+++ b/tests/demo.cast
@@ -0,0 +1,40 @@
+{"env": {"TERM": "xterm-256color", "SHELL": "/usr/local/bin/fish"}, "width": 75, "height": 18, "timestamp": 1509091818, "version": 2, "idle_time_limit": 2.0}
+[0.089436, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[0.100989, "o", "\u001b[?2004h"]
+[0.164215, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[0.164513, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K"]
+[0.164709, "o", "\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"]
+[1.511526, "i", "v"]
+[1.511937, "o", "v"]
+[1.512148, "o", "\b\u001b[38;2;0;95;215mv\u001b[30m\u001b(B\u001b[m"]
+[1.514564, "o", "\u001b[38;2;85;85;85mim tests/vim.cast \u001b[18D\u001b[30m\u001b(B\u001b[m"]
+[1.615727, "i", "i"]
+[1.616261, "o", "\u001b[38;2;0;95;215mi\u001b[38;2;85;85;85mm tests/vim.cast \u001b[17D\u001b[30m\u001b(B\u001b[m"]
+[1.694908, "i", "m"]
+[1.695262, "o", "\u001b[38;2;0;95;215mm\u001b[38;2;85;85;85m tests/vim.cast \u001b[16D\u001b[30m\u001b(B\u001b[m"]
+[2.751713, "i", "\r"]
+[2.752186, "o", "\u001b[K\r\n\u001b[30m"]
+[2.752381, "o", "\u001b(B\u001b[m\u001b[?2004l"]
+[2.752718, "o", "\u001b]0;vim /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m\r"]
+[2.86619, "o", "\u001b[?1000h\u001b[?2004h\u001b[?1049h\u001b[?1h\u001b=\u001b[?2004h"]
+[2.867669, "o", "\u001b[1;18r\u001b[?12h\u001b[?12l\u001b[27m\u001b[29m\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[H\u001b[2J\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H\u001b[>c"]
+[2.868169, "i", "\u001b[2;2R\u001b[>0;95;0c"]
+[2.869918, "o", "\u001b[?1000l\u001b[?1002h\u001b[?12$p"]
+[2.870136, "o", "\u001b[?25l\u001b[1;1H\u001b[93m1 \u001b[m\u001b[38;5;231m\u001b[48;5;235m\r\n\u001b[38;5;59m\u001b[48;5;236m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "]
+[2.870245, "o", " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[17;1H\u001b[1m\u001b[38;5;231m\u001b[48;5;236m[No Name] (unix/utf-8/) (line 0/1, col 000)\u001b[m\u001b[38;5;231m\u001b[48;5;235m\u001b[3;30HVIM - Vi IMproved\u001b[5;30Hversion 8.0.1171\u001b[6;26Hby Bram Moolenaar et al.\u001b[7;17HVim is open source and freely distributable\u001b[9;24HBecome a registered Vim user!\u001b[10;15Htype :help register\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for information \u001b[12;15Htype :q\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m to exit \u001b[13;15Htype :help\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m or \u001b[38;5;59m\u001b[48;5;236m<F1>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for on-line help\u001b[14;15Htype :help version8\u001b[38;5;59m\u001b[48;5;236m<Enter>\u001b[m\u001b[38;5;231m\u001b[48;5;235m for version"]
+[2.870302, "o", " info\u001b[1;5H\u001b[?25h"]
+[5.63147, "i", ":"]
+[5.631755, "o", "\u001b[?25l\u001b[18;65H:\u001b[1;5H"]
+[5.631934, "o", "\u001b[18;65H\u001b[K\u001b[18;1H:\u001b[?2004l\u001b[?2004h\u001b[?25h"]
+[6.16692, "i", "q"]
+[6.167137, "o", "q\u001b[?25l\u001b[?25h"]
+[7.463349, "i", "\r"]
+[7.463561, "o", "\r"]
+[7.498922, "o", "\u001b[?25l\u001b[?1002l\u001b[?2004l"]
+[7.604236, "o", "\u001b[18;1H\u001b[K\u001b[18;1H\u001b[?2004l\u001b[?1l\u001b>\u001b[?25h\u001b[?1049l"]
+[7.612576, "o", "\u001b[?2004h"]
+[7.655999, "o", "\u001b]0;fish /Users/sickill/code/asciinema/asciinema\u0007\u001b[30m\u001b(B\u001b[m"]
+[7.656239, "o", "\u001b[38;5;237m⏎\u001b(B\u001b[m \r⏎ \r\u001b[2K\u001b[32m~/c/a/asciinema\u001b[30m\u001b(B\u001b[m (develop ↩☡=) \u001b[30m\u001b(B\u001b[m\u001b[K"]
+[11.891762, "i", "\u0004"]
+[11.893297, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[30m\u001b(B\u001b[m"]
+[11.89348, "o", "\u001b[?2004l"]
diff --git a/tests/demo.json b/tests/demo.json
new file mode 100644
index 0000000..68092ae
--- /dev/null
+++ b/tests/demo.json
@@ -0,0 +1,114 @@
+{
+ "version": 1,
+ "width": 80,
+ "height": 40,
+ "duration": 6.46111,
+ "command": "/bin/bash",
+ "title": null,
+ "env": {
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/bash"
+ },
+ "stdout": [
+ [
+ 0.013659,
+ "\u001b[?1034hbash-3.2$ "
+ ],
+ [
+ 1.923187,
+ "v"
+ ],
+ [
+ 0.064049,
+ "i"
+ ],
+ [
+ 0.032034,
+ "m"
+ ],
+ [
+ 0.19157,
+ "\r\n"
+ ],
+ [
+ 0.032342,
+ "\u001b[?1049h\u001b[?1h\u001b=\u001b[2;1H▽\u001b[6n\u001b[2;1H \u001b[1;1H"
+ ],
+ [
+ 0.001436,
+ "\u001b[1;40r\u001b[?12;25h\u001b[?12l\u001b[?25h\u001b[27m\u001b[m\u001b[H\u001b[2J\u001b[>c"
+ ],
+ [
+ 0.000311,
+ "\u001b[?25l\u001b[1;1H\u001b[33m 1 \u001b[m\r\n\u001b[1m\u001b[34m~ \u001b[3;1H~ \u001b[4;1H~ \u001b[5;1H~ \u001b[6;1H~ \u001b[7;1H~ \u001b[8;1H~ \u001b[9;1H~ \u001b[10;1H~ \u001b[11;1H~ \u001b[12;1H~ \u001b[13;1H~ "
+ ],
+ [
+ 3.9e-05,
+ " \u001b[14;1H~ \u001b[15;1H~ \u001b[16;1H~ \u001b[17;1H~ \u001b[18;1H~ \u001b[19;1H~ \u001b[20;1H~ \u001b[21;1H~ \u001b[22;1H~ \u001b[23;1H~ \u001b[24;1H~ \u001b[25;1H~ "
+ ],
+ [
+ 9.2e-05,
+ " \u001b[26;1H~ \u001b[27;1H~ \u001b[28;1H~ \u001b[29;1H~ \u001b[30;1H~ \u001b[31;1H~ \u001b[32;1H~ \u001b[33;1H~ \u001b[34;1H~ \u001b[35;1H~ \u001b[36;1H~ \u001b[37;"
+ ],
+ [
+ 2.4e-05,
+ "1H~ \u001b[38;1H~ \u001b[m\u001b[39;1H\u001b[1m\u001b[7m[No Name] \u001b[m\u001b[14;32HVIM - Vi IMproved\u001b[16;33Hversion 7.4.8056\u001b[17;29Hby Bram Moolenaar et al.\u001b[18;19HVim is open source and freely distributable\u001b[20;26HBecome a registered Vim user!\u001b[21;18Htype :help register\u001b[32m<Enter>\u001b[m for information \u001b[23;18Htype :q\u001b[32m<Enter>\u001b[m to exit \u001b[24;18Htype :help\u001b[32m<Enter>\u001b[m or \u001b[32m<F1>\u001b[m for on-line help\u001b[25;18Htype :help version7\u001b[32m<Enter>\u001b[m for version info\u001b[1;5H\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 1.070242,
+ "\u001b[?25l\u001b[40;1H:"
+ ],
+ [
+ 2.3e-05,
+ "\u001b[?12l\u001b[?25h"
+ ],
+ [
+ 0.503964,
+ "q"
+ ],
+ [
+ 0.151903,
+ "u"
+ ],
+ [
+ 0.04002,
+ "i"
+ ],
+ [
+ 0.088084,
+ "t"
+ ],
+ [
+ 0.287636,
+ "\r"
+ ],
+ [
+ 0.002178,
+ "\u001b[?25l\u001b[40;1H\u001b[K\u001b[40;1H\u001b[?1l\u001b>\u001b[?12l\u001b[?25h\u001b[?1049l"
+ ],
+ [
+ 0.000999,
+ "bash-3.2$ "
+ ],
+ [
+ 1.58912,
+ "e"
+ ],
+ [
+ 0.184114,
+ "x"
+ ],
+ [
+ 0.087915,
+ "i"
+ ],
+ [
+ 0.103987,
+ "t"
+ ],
+ [
+ 0.087613,
+ "\r\n"
+ ]
+ ]
+}
diff --git a/tests/distros.sh b/tests/distros.sh
new file mode 100755
index 0000000..c34d272
--- /dev/null
+++ b/tests/distros.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+readonly DISTROS=(
+ 'arch'
+ 'alpine'
+ 'centos'
+ 'debian'
+ 'fedora'
+ 'ubuntu'
+)
+
+readonly DOCKER='docker'
+
+# do not redefine builtin `test`
+test_() {
+ local -r tag="${1}"
+
+ local -ra docker_opts=(
+ "--tag=asciinema/asciinema:${tag}"
+ "--file=tests/distros/Dockerfile.${tag}"
+ )
+
+ printf "\e[1;32mTesting on %s...\e[0m\n\n" "${tag}"
+
+ # shellcheck disable=SC2068
+ "${DOCKER}" build ${docker_opts[@]} .
+
+ "${DOCKER}" run --rm -it "asciinema/asciinema:${tag}" tests/integration.sh
+}
+
+
+for distro in "${DISTROS[@]}"; do
+ test_ "${distro}"
+done
+
+printf "\n\e[1;32mAll tests passed.\e[0m\n"
diff --git a/tests/distros/Dockerfile.alpine b/tests/distros/Dockerfile.alpine
new file mode 100644
index 0000000..9716325
--- /dev/null
+++ b/tests/distros/Dockerfile.alpine
@@ -0,0 +1,19 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/alpine:3.15
+
+# https://github.com/actions/runner/issues/241
+RUN apk --no-cache add bash ca-certificates make python3 util-linux
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.arch b/tests/distros/Dockerfile.arch
new file mode 100644
index 0000000..3224495
--- /dev/null
+++ b/tests/distros/Dockerfile.arch
@@ -0,0 +1,22 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/archlinux:latest
+
+RUN pacman-key --init \
+ && pacman --sync --refresh --sysupgrade --noconfirm make python3 \
+ && printf "LANG=en_US.UTF-8\n" > /etc/locale.conf \
+ && locale-gen \
+ && pacman --sync --clean --clean --noconfirm
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.centos b/tests/distros/Dockerfile.centos
new file mode 100644
index 0000000..bc4fd7e
--- /dev/null
+++ b/tests/distros/Dockerfile.centos
@@ -0,0 +1,18 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/centos:7
+
+RUN yum install -y epel-release && yum install -y make python36 && yum clean all
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.debian b/tests/distros/Dockerfile.debian
new file mode 100644
index 0000000..6c14287
--- /dev/null
+++ b/tests/distros/Dockerfile.debian
@@ -0,0 +1,33 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/debian:bullseye
+
+ENV DEBIAN_FRONTENT="noninteractive"
+
+RUN apt-get update \
+ && apt-get install -y \
+ ca-certificates \
+ locales \
+ make \
+ procps \
+ python3 \
+ && localedef \
+ -i en_US \
+ -c \
+ -f UTF-8 \
+ -A /usr/share/locale/locale.alias \
+ en_US.UTF-8 \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENV SHELL="/bin/bash"
+
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.fedora b/tests/distros/Dockerfile.fedora
new file mode 100644
index 0000000..e5abb51
--- /dev/null
+++ b/tests/distros/Dockerfile.fedora
@@ -0,0 +1,20 @@
+# syntax=docker/dockerfile:1.3
+
+# https://medium.com/nttlabs/ubuntu-21-10-and-fedora-35-do-not-work-on-docker-20-10-9-1cd439d9921
+# https://www.mail-archive.com/ubuntu-bugs@lists.ubuntu.com/msg5971024.html
+FROM registry.fedoraproject.org/fedora:34
+
+RUN dnf install -y make python3 procps && dnf clean all
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+ENV SHELL="/bin/bash"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+# vim:ft=dockerfile
diff --git a/tests/distros/Dockerfile.ubuntu b/tests/distros/Dockerfile.ubuntu
new file mode 100644
index 0000000..38223c2
--- /dev/null
+++ b/tests/distros/Dockerfile.ubuntu
@@ -0,0 +1,32 @@
+# syntax=docker/dockerfile:1.3
+
+FROM docker.io/library/ubuntu:20.04
+
+ENV DEBIAN_FRONTENT="noninteractive"
+
+RUN apt-get update \
+ && apt-get install -y \
+ ca-certificates \
+ locales \
+ make \
+ python3 \
+ && localedef \
+ -i en_US \
+ -c \
+ -f UTF-8 \
+ -A /usr/share/locale/locale.alias \
+ en_US.UTF-8 \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src/app
+
+COPY asciinema/ asciinema/
+COPY tests/ tests/
+
+ENV LANG="en_US.utf8"
+
+USER nobody
+
+ENTRYPOINT ["/bin/bash"]
+
+# vim:ft=dockerfile
diff --git a/tests/integration.sh b/tests/integration.sh
new file mode 100755
index 0000000..9f4f5d1
--- /dev/null
+++ b/tests/integration.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+
+set -eExuo pipefail
+
+if ! command -v "pkill" >/dev/null 2>&1; then
+ printf "error: pkill not installed\n"
+ exit 1
+fi
+
+python3 -V
+
+ASCIINEMA_CONFIG_HOME="$(
+ mktemp -d 2>/dev/null || mktemp -d -t asciinema-config-home
+)"
+
+export ASCIINEMA_CONFIG_HOME
+
+TMP_DATA_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t asciinema-data-dir)"
+
+trap 'rm -rf ${ASCIINEMA_CONFIG_HOME} ${TMP_DATA_DIR}' EXIT
+
+asciinema() {
+ python3 -m asciinema "${@}"
+}
+
+## test help message
+
+asciinema -h
+
+## test version command
+
+asciinema --version
+
+## test auth command
+
+asciinema auth
+
+## test play command
+
+# asciicast v1
+asciinema play -s 5 tests/demo.json
+asciinema play -s 5 -i 0.2 tests/demo.json
+# shellcheck disable=SC2002
+cat tests/demo.json | asciinema play -s 5 -
+
+# asciicast v2
+asciinema play -s 5 tests/demo.cast
+asciinema play -s 5 -i 0.2 tests/demo.cast
+# shellcheck disable=SC2002
+cat tests/demo.cast | asciinema play -s 5 -
+
+## test cat command
+
+# asciicast v1
+asciinema cat tests/demo.json
+# shellcheck disable=SC2002
+cat tests/demo.json | asciinema cat -
+
+# asciicast v2
+asciinema cat tests/demo.cast
+# shellcheck disable=SC2002
+cat tests/demo.cast | asciinema cat -
+
+## test rec command
+
+# normal program
+asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/1a.cast"
+grep '"o",' "${TMP_DATA_DIR}/1a.cast"
+
+# very quickly exiting program
+asciinema rec -c whoami "${TMP_DATA_DIR}/1b.cast"
+grep '"o",' "${TMP_DATA_DIR}/1b.cast"
+
+# signal handling
+bash -c "sleep 1; pkill -28 -n -f 'm asciinema'" &
+asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/2.cast"
+
+bash -c "sleep 1; pkill -n -f 'bash -c echo t3st'" &
+asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/3.cast"
+
+bash -c "sleep 1; pkill -9 -n -f 'bash -c echo t3st'" &
+asciinema rec -c 'bash -c "echo t3st; sleep 2; echo ok"' "${TMP_DATA_DIR}/4.cast"
+
+# with stdin recording
+echo "ls" | asciinema rec --stdin -c 'bash -c "sleep 1"' "${TMP_DATA_DIR}/5.cast"
+cat "${TMP_DATA_DIR}/5.cast"
+grep '"i", "ls\\n"' "${TMP_DATA_DIR}/5.cast"
+grep '"o",' "${TMP_DATA_DIR}/5.cast"
+
+# raw output recording
+asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/6.raw"
+
+# appending to existing recording
+asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast"
+asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast"
diff --git a/tests/pty_test.py b/tests/pty_test.py
new file mode 100644
index 0000000..0f309c7
--- /dev/null
+++ b/tests/pty_test.py
@@ -0,0 +1,54 @@
+import os
+import pty
+from typing import Any, List, Union
+
+import asciinema.pty_
+
+from .test_helper import Test
+
+
+class Writer:
+ def __init__(self) -> None:
+ self.data: List[Union[float, str]] = []
+
+ def write_stdout(self, _ts: float, data: Any) -> None:
+ self.data.append(data)
+
+ def write_stdin(self, ts: float, data: Any) -> None:
+ raise NotImplementedError
+
+
+class TestRecord(Test):
+ def setUp(self) -> None:
+ self.real_os_write = os.write
+ os.write = self.os_write # type: ignore
+
+ def tearDown(self) -> None:
+ os.write = self.real_os_write
+
+ def os_write(self, fd: int, data: Any) -> None:
+ if fd != pty.STDOUT_FILENO:
+ self.real_os_write(fd, data)
+
+ @staticmethod
+ def test_record_command_writes_to_stdout() -> None:
+ writer = Writer()
+
+ command = [
+ "python3",
+ "-c",
+ (
+ "import sys"
+ "; import time"
+ "; sys.stdout.write('foo')"
+ "; sys.stdout.flush()"
+ "; time.sleep(0.01)"
+ "; sys.stdout.write('bar')"
+ ),
+ ]
+
+ asciinema.pty_.record(
+ command, {}, writer, lambda: (80, 24), lambda s: None, {}
+ )
+
+ assert writer.data == [b"foo", b"bar"]
diff --git a/tests/test_helper.py b/tests/test_helper.py
new file mode 100644
index 0000000..03b7e97
--- /dev/null
+++ b/tests/test_helper.py
@@ -0,0 +1,16 @@
+import sys
+from codecs import StreamReader
+from io import StringIO
+from typing import Optional, TextIO, Union
+
+stdout: Optional[Union[TextIO, StreamReader]] = None
+
+
+class Test:
+ def setUp(self) -> None:
+ global stdout # pylint: disable=global-statement
+ self.real_stdout = sys.stdout
+ sys.stdout = stdout = StringIO()
+
+ def tearDown(self) -> None:
+ sys.stdout = self.real_stdout