Line data Source code
1 : #include "test_helpers.h"
2 : #include "platform/terminal.h"
3 : #include "platform/path.h"
4 : #include <signal.h>
5 : #include <stdlib.h>
6 : #include <string.h>
7 : #include <unistd.h>
8 : #include <locale.h>
9 : #include <sys/wait.h>
10 :
11 1 : void test_platform(void) {
12 :
13 : /* ── terminal_wcwidth ───────────────────────────────────────────── */
14 :
15 1 : setlocale(LC_ALL, "");
16 :
17 : /* ASCII printable characters: width 1 */
18 1 : ASSERT(terminal_wcwidth('A') == 1, "wcwidth: ASCII letter should be 1");
19 1 : ASSERT(terminal_wcwidth(' ') == 1, "wcwidth: space should be 1");
20 1 : ASSERT(terminal_wcwidth('0') == 1, "wcwidth: digit should be 1");
21 :
22 : /* Control characters: width 0 (not negative) */
23 1 : ASSERT(terminal_wcwidth('\n') == 0, "wcwidth: newline should be 0");
24 1 : ASSERT(terminal_wcwidth('\t') == 0, "wcwidth: tab should be 0");
25 1 : ASSERT(terminal_wcwidth(0x01) == 0, "wcwidth: control char should be 0");
26 :
27 : /* Common accented Latin characters: width 1 */
28 1 : ASSERT(terminal_wcwidth(0x00E9) == 1, "wcwidth: e-acute (U+00E9) should be 1");
29 1 : ASSERT(terminal_wcwidth(0x00E1) == 1, "wcwidth: a-acute (U+00E1) should be 1");
30 1 : ASSERT(terminal_wcwidth(0x0151) == 1, "wcwidth: o-double-acute (U+0151) should be 1");
31 :
32 : /* Combining diacritic: width 0 */
33 1 : ASSERT(terminal_wcwidth(0x0300) == 0, "wcwidth: combining grave (U+0300) should be 0");
34 :
35 : /* Zero-width joiner: width 0 */
36 1 : ASSERT(terminal_wcwidth(0x200D) == 0, "wcwidth: zero-width joiner (U+200D) should be 0");
37 :
38 : /* CJK ideograph: width 2 */
39 1 : ASSERT(terminal_wcwidth(0x4E2D) == 2, "wcwidth: CJK U+4E2D should be 2");
40 1 : ASSERT(terminal_wcwidth(0x3042) == 2, "wcwidth: Hiragana U+3042 should be 2");
41 :
42 : /* ── terminal_is_tty ────────────────────────────────────────────── */
43 :
44 : /* When stdout is a pipe (as in test runner), fd 1 is not a tty */
45 : /* We cannot assert a specific value since it depends on the test
46 : * environment, but the function must return 0 or 1 without crashing. */
47 1 : int r = terminal_is_tty(STDOUT_FILENO);
48 1 : ASSERT(r == 0 || r == 1, "terminal_is_tty must return 0 or 1");
49 :
50 : /* Invalid fd must return 0 */
51 1 : ASSERT(terminal_is_tty(-1) == 0, "terminal_is_tty(-1) should return 0");
52 1 : ASSERT(terminal_is_tty(9999) == 0, "terminal_is_tty(9999) should return 0");
53 :
54 : /* ── terminal_cols ──────────────────────────────────────────────── */
55 :
56 : /* When stdout is not a tty (e.g. piped in CI), must fall back to 80. */
57 1 : if (!terminal_is_tty(STDOUT_FILENO)) {
58 1 : ASSERT(terminal_cols() == 80,
59 : "terminal_cols() should return 80 when stdout is not a tty");
60 : } else {
61 : /* On a real terminal it must be a positive value. */
62 0 : ASSERT(terminal_cols() > 0, "terminal_cols() must be positive");
63 : }
64 :
65 : /* ── terminal_rows ──────────────────────────────────────────────── */
66 :
67 1 : int rows = terminal_rows();
68 1 : if (!terminal_is_tty(STDOUT_FILENO)) {
69 1 : ASSERT(rows == 0, "terminal_rows() should return 0 when stdout is not a tty");
70 : } else {
71 0 : ASSERT(rows > 0, "terminal_rows() must be positive on a real terminal");
72 : }
73 :
74 : /* ── terminal_raw_enter / terminal_raw_exit ─────────────────────── */
75 :
76 : /* When stdin is not a tty (as in test runner), raw_enter must return NULL
77 : * gracefully (tcgetattr will fail). */
78 1 : if (!terminal_is_tty(STDIN_FILENO)) {
79 1 : TermRawState *s = terminal_raw_enter();
80 1 : ASSERT(s == NULL,
81 : "terminal_raw_enter should return NULL when stdin is not a tty");
82 : /* terminal_raw_exit(NULL) and terminal_raw_exit(&NULL) must be safe. */
83 1 : terminal_raw_exit(NULL);
84 1 : terminal_raw_exit(&s); /* s is already NULL */
85 : }
86 :
87 : /* ── terminal_read_password — guard clauses ─────────────────────── */
88 :
89 : char pwbuf[64];
90 : /* NULL buf → -1 */
91 1 : ASSERT(terminal_read_password("test", NULL, 64) == -1,
92 : "terminal_read_password: NULL buf should return -1");
93 : /* size == 0 → -1 */
94 1 : ASSERT(terminal_read_password("test", pwbuf, 0) == -1,
95 : "terminal_read_password: size 0 should return -1");
96 :
97 : /* Non-tty stdin path: getline on an empty/closed stream returns -1. */
98 1 : if (!terminal_is_tty(STDIN_FILENO)) {
99 1 : int n = terminal_read_password("test", pwbuf, sizeof(pwbuf));
100 1 : ASSERT(n == -1 || n >= 0,
101 : "terminal_read_password non-tty: must not crash");
102 : }
103 :
104 : /* ── platform_home_dir ──────────────────────────────────────────── */
105 :
106 1 : const char *home = platform_home_dir();
107 1 : ASSERT(home != NULL, "platform_home_dir should not return NULL");
108 1 : ASSERT(home[0] == '/', "platform_home_dir should return an absolute path");
109 :
110 : /* Must still work when HOME is unset (getpwuid fallback) */
111 1 : char saved_home[4096] = {0};
112 1 : const char *env_home = getenv("HOME");
113 1 : if (env_home) snprintf(saved_home, sizeof(saved_home), "%s", env_home);
114 1 : unsetenv("HOME");
115 1 : home = platform_home_dir();
116 1 : ASSERT(home != NULL, "platform_home_dir should fall back to getpwuid");
117 1 : if (saved_home[0]) setenv("HOME", saved_home, 1);
118 :
119 : /* ── platform_cache_dir ─────────────────────────────────────────── */
120 :
121 : /* Default: ~/.cache */
122 1 : unsetenv("XDG_CACHE_HOME");
123 1 : const char *cache = platform_cache_dir();
124 1 : ASSERT(cache != NULL, "platform_cache_dir should not return NULL");
125 1 : ASSERT(strstr(cache, ".cache") != NULL,
126 : "platform_cache_dir default should contain '.cache'");
127 :
128 : /* XDG override */
129 1 : setenv("XDG_CACHE_HOME", "/tmp/test-xdg-cache", 1);
130 1 : cache = platform_cache_dir();
131 1 : ASSERT(cache != NULL, "platform_cache_dir XDG should not return NULL");
132 1 : ASSERT(strcmp(cache, "/tmp/test-xdg-cache") == 0,
133 : "platform_cache_dir should respect XDG_CACHE_HOME");
134 1 : unsetenv("XDG_CACHE_HOME");
135 :
136 : /* ── platform_config_dir ────────────────────────────────────────── */
137 :
138 : /* Default: ~/.config */
139 1 : unsetenv("XDG_CONFIG_HOME");
140 1 : const char *cfg = platform_config_dir();
141 1 : ASSERT(cfg != NULL, "platform_config_dir should not return NULL");
142 1 : ASSERT(strstr(cfg, ".config") != NULL,
143 : "platform_config_dir default should contain '.config'");
144 :
145 : /* XDG override */
146 1 : setenv("XDG_CONFIG_HOME", "/tmp/test-xdg-config", 1);
147 1 : cfg = platform_config_dir();
148 1 : ASSERT(cfg != NULL, "platform_config_dir XDG should not return NULL");
149 1 : ASSERT(strcmp(cfg, "/tmp/test-xdg-config") == 0,
150 : "platform_config_dir should respect XDG_CONFIG_HOME");
151 1 : unsetenv("XDG_CONFIG_HOME");
152 :
153 : /* ── SIGWINCH resize notifications ──────────────────────────────── */
154 :
155 : /* Before enabling the handler, consume should be a no-op. */
156 1 : ASSERT(terminal_consume_resize() == 0,
157 : "consume_resize before enable returns 0");
158 :
159 1 : terminal_enable_resize_notifications();
160 : /* Idempotent. */
161 1 : terminal_enable_resize_notifications();
162 :
163 : /* Still nothing pending until a signal is delivered. */
164 1 : ASSERT(terminal_consume_resize() == 0,
165 : "no resize pending right after enable");
166 :
167 : /* Simulate a resize by raising SIGWINCH and let the handler run. */
168 1 : raise(SIGWINCH);
169 1 : ASSERT(terminal_consume_resize() == 1, "resize observed after SIGWINCH");
170 : /* Flag should clear on first consume. */
171 1 : ASSERT(terminal_consume_resize() == 0, "resize flag clears after read");
172 :
173 : /* ── terminal_wait_key ──────────────────────────────────────────── */
174 :
175 : /* With a 0ms timeout and no piped input pending, wait_key should
176 : * return 0 (timeout) quickly. The test runner's stdin is closed or
177 : * empty, so poll() either times out or reports a hangup — both
178 : * count as "no actionable key ready". */
179 1 : int wk = terminal_wait_key(0);
180 1 : ASSERT(wk == 0 || wk == 1 || wk == -1,
181 : "wait_key returns a valid sentinel");
182 :
183 : /* Raise SIGWINCH mid-wait: poll returns EINTR → we return -1. */
184 1 : raise(SIGWINCH);
185 : /* The handler already ran and set the flag; poll may or may not
186 : * actually be interrupted (depends on whether raise delivers
187 : * synchronously). Either way consume_resize should now be 1. */
188 1 : ASSERT(terminal_consume_resize() == 1,
189 : "SIGWINCH raised right before wait still observable");
190 :
191 : /* ── terminal_install_cleanup_handlers ──────────────────────────── */
192 :
193 : /* Passing NULL must be a safe no-op (no crash). */
194 1 : terminal_install_cleanup_handlers(NULL);
195 :
196 : /* Verify that after installing handlers, SIGTERM is caught (not the
197 : * default SIG_DFL which would terminate us) and re-raised with
198 : * SIG_DFL — we use a child process so the parent survives. */
199 : {
200 1 : pid_t pid = fork();
201 1 : ASSERT(pid >= 0, "fork() for SIGTERM test must succeed");
202 1 : if (pid == 0) {
203 : /* Child: set up a dummy TermRawState (not a real tty here,
204 : * so tcsetattr will fail silently — that is acceptable). */
205 0 : TermRawState *dummy = terminal_raw_enter();
206 : /* raw_enter returns NULL when stdin is not a tty (test env);
207 : * install_cleanup_handlers is documented to accept NULL (no-op)
208 : * so this is safe. The important thing is it does not crash. */
209 0 : terminal_install_cleanup_handlers(dummy);
210 : /* Send ourselves SIGTERM — the handler should re-raise with
211 : * SIG_DFL, killing the child with SIGTERM. */
212 0 : raise(SIGTERM);
213 : /* Should not reach here after re-raise with SIG_DFL. */
214 0 : _exit(42);
215 : }
216 : /* Parent waits for the child. */
217 1 : int status = 0;
218 1 : waitpid(pid, &status, 0);
219 : /* Child must have been terminated by a signal (SIGTERM), not
220 : * exited normally with code 42. */
221 1 : ASSERT(WIFSIGNALED(status),
222 : "install_cleanup_handlers: child terminated by signal");
223 1 : ASSERT(WTERMSIG(status) == SIGTERM,
224 : "install_cleanup_handlers: child terminated by SIGTERM");
225 : }
226 : }
|