File size: 13,368 Bytes
4b03eed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# -*- coding: utf-8 -*-
"""Test cases for :class:`LocalBackend` and the backend helpers.



Exercises the three abstract primitives (``exec_shell``, ``read_file``,

``write_file``) plus the derived filesystem helpers (``file_exists``,

``is_dir``, ``list_dir``, ``stat_mtime``, ``delete_path``) of the

host-local backend, and the module-level ``normalize_newlines`` helper.



``LocalBackend`` is designed to run on every platform (it spawns

programs from an argv list without a shell and implements the

filesystem helpers with native ``os.*`` calls), so the bulk of this

module runs on Windows too.  Only the handful of cases that genuinely

rely on a POSIX shell / POSIX-only utilities are skipped on Windows.

"""

import os
import sys
import tempfile
import unittest
from unittest.async_case import IsolatedAsyncioTestCase

from agentscope.tool import ExecResult, LocalBackend
from agentscope.tool._builtin._backend import _normalize_newlines

_IS_WINDOWS = sys.platform == "win32"


class TestNormalizeNewlines(unittest.TestCase):
    """Unit tests for the ``normalize_newlines`` helper (pure, no I/O)."""

    def test_crlf_collapsed_to_lf(self) -> None:
        """Windows ``\\r\\n`` is collapsed to a single ``\\n``."""
        self.assertEqual(
            _normalize_newlines("a\r\nb\r\nc"),
            "a\nb\nc",
        )

    def test_lone_cr_collapsed_to_lf(self) -> None:
        """Classic-Mac lone ``\\r`` is collapsed to ``\\n``."""
        self.assertEqual(_normalize_newlines("a\rb\rc"), "a\nb\nc")

    def test_mixed_endings(self) -> None:
        """A mix of ``\\r\\n``, ``\\r`` and ``\\n`` normalizes uniformly."""
        self.assertEqual(
            _normalize_newlines("a\r\nb\rc\nd"),
            "a\nb\nc\nd",
        )

    def test_plain_lf_unchanged(self) -> None:
        """Text already using ``\\n`` is returned unchanged."""
        self.assertEqual(_normalize_newlines("a\nb\nc"), "a\nb\nc")

    def test_no_double_collapse(self) -> None:
        """``\\r\\n`` becomes exactly one ``\\n`` (not two)."""
        self.assertEqual(_normalize_newlines("a\r\n\r\nb"), "a\n\nb")


class TestLocalBackendExec(IsolatedAsyncioTestCase):
    """Test cases for ``LocalBackend.exec_shell``."""

    async def asyncSetUp(self) -> None:
        """Build a fresh backend per test."""
        self.backend = LocalBackend()

    async def test_exec_returns_stdout(self) -> None:
        """A program's stdout/exit code are captured into ``ExecResult``.



        Uses the current interpreter so the test is portable across

        platforms (no reliance on ``echo`` / shell builtins).

        """
        result = await self.backend.exec_shell(
            [sys.executable, "-c", "print('hello world')"],
        )
        self.assertIsInstance(result, ExecResult)
        self.assertTrue(result.ok())
        self.assertEqual(result.exit_code, 0)
        self.assertEqual(result.stdout.decode().strip(), "hello world")
        self.assertEqual(result.stderr, b"")

    async def test_exec_captures_stderr_and_exit_code(self) -> None:
        """A non-zero exit and stderr bytes are reported faithfully."""
        result = await self.backend.exec_shell(
            [
                sys.executable,
                "-c",
                "import sys; sys.stderr.write('boom'); sys.exit(3)",
            ],
        )
        self.assertFalse(result.ok())
        self.assertEqual(result.exit_code, 3)
        self.assertEqual(result.stderr.decode().strip(), "boom")

    async def test_exec_argv_not_split_by_shell(self) -> None:
        """Arguments are passed verbatim (no shell word-splitting/globbing).



        A single argument containing spaces and shell metacharacters must

        reach the program intact, proving no shell is interposed.

        """
        tricky = "a b $(echo x) | & ; '\""
        result = await self.backend.exec_shell(
            [sys.executable, "-c", "import sys; print(sys.argv[1])", tricky],
        )
        self.assertTrue(result.ok())
        self.assertEqual(result.stdout.decode().rstrip("\r\n"), tricky)

    async def test_exec_cwd_is_respected(self) -> None:
        """``cwd`` sets the subprocess working directory."""
        with tempfile.TemporaryDirectory() as tmp:
            result = await self.backend.exec_shell(
                [sys.executable, "-c", "import os; print(os.getcwd())"],
                cwd=tmp,
            )
            self.assertTrue(result.ok())
            self.assertEqual(
                os.path.realpath(result.stdout.decode().strip()),
                os.path.realpath(tmp),
            )

    async def test_missing_executable_returns_127(self) -> None:
        """An unspawnable executable yields exit code 127 (not an exception).



        Mirrors a shell's "command not found" so callers see a normal

        non-zero ``ExecResult``.

        """
        result = await self.backend.exec_shell(
            ["this_executable_does_not_exist_xyz", "--nope"],
        )
        self.assertEqual(result.exit_code, 127)
        self.assertEqual(result.stdout, b"")
        self.assertNotEqual(result.stderr, b"")

    async def test_timeout_returns_minus_one(self) -> None:
        """A command exceeding ``timeout`` is killed and reports -1.



        The sentinel ``ExecResult(exit_code=-1, stderr=b"timed out")`` is

        what Grep/Glob check for, so it is asserted exactly.

        """
        result = await self.backend.exec_shell(
            [sys.executable, "-c", "import time; time.sleep(10)"],
            timeout=0.2,
        )
        self.assertEqual(result.exit_code, -1)
        self.assertEqual(result.stderr, b"timed out")


class TestLocalBackendFileIO(IsolatedAsyncioTestCase):
    """Test cases for ``read_file`` / ``write_file`` round-trips."""

    async def asyncSetUp(self) -> None:
        """Build a backend and a temp dir per test."""
        # pylint: disable=consider-using-with
        self.backend = LocalBackend()
        self.temp_dir = tempfile.TemporaryDirectory()

    async def asyncTearDown(self) -> None:
        """Drop the temp dir."""
        self.temp_dir.cleanup()

    async def test_write_then_read_roundtrip(self) -> None:
        """Bytes written are read back verbatim."""
        path = os.path.join(self.temp_dir.name, "file.txt")
        payload = b"hello\nworld\n"
        await self.backend.write_file(path, payload)
        self.assertEqual(await self.backend.read_file(path), payload)

    async def test_write_creates_parent_dirs(self) -> None:
        """``write_file`` creates missing parent directories."""
        path = os.path.join(self.temp_dir.name, "a", "b", "c", "file.txt")
        await self.backend.write_file(path, b"x")
        self.assertTrue(os.path.exists(path))
        self.assertEqual(await self.backend.read_file(path), b"x")

    async def test_write_preserves_binary_and_crlf(self) -> None:
        """Raw bytes (incl. ``\\r\\n`` and NULs) survive the round-trip.



        The backend deals in raw bytes; newline normalization happens

        later in the text layer, never here.

        """
        path = os.path.join(self.temp_dir.name, "bin.dat")
        payload = b"a\r\nb\x00\xffc"
        await self.backend.write_file(path, payload)
        self.assertEqual(await self.backend.read_file(path), payload)

    async def test_read_missing_file_raises(self) -> None:
        """Reading a non-existent file raises ``FileNotFoundError``."""
        path = os.path.join(self.temp_dir.name, "nope.txt")
        with self.assertRaises(FileNotFoundError):
            await self.backend.read_file(path)


class TestLocalBackendFilesystemHelpers(IsolatedAsyncioTestCase):
    """Test cases for the derived filesystem helpers (native ``os.*``)."""

    async def asyncSetUp(self) -> None:
        """Build a backend and a temp dir per test."""
        # pylint: disable=consider-using-with
        self.backend = LocalBackend()
        self.temp_dir = tempfile.TemporaryDirectory()

    async def asyncTearDown(self) -> None:
        """Drop the temp dir."""
        self.temp_dir.cleanup()

    async def test_file_exists(self) -> None:
        """``file_exists`` is True for files and dirs, False otherwise."""
        path = os.path.join(self.temp_dir.name, "f.txt")
        await self.backend.write_file(path, b"x")
        self.assertTrue(await self.backend.file_exists(path))
        self.assertTrue(await self.backend.file_exists(self.temp_dir.name))
        self.assertFalse(
            await self.backend.file_exists(
                os.path.join(self.temp_dir.name, "missing"),
            ),
        )

    async def test_is_dir(self) -> None:
        """``is_dir`` distinguishes directories from files."""
        path = os.path.join(self.temp_dir.name, "f.txt")
        await self.backend.write_file(path, b"x")
        self.assertTrue(await self.backend.is_dir(self.temp_dir.name))
        self.assertFalse(await self.backend.is_dir(path))
        self.assertFalse(
            await self.backend.is_dir(
                os.path.join(self.temp_dir.name, "missing"),
            ),
        )

    async def test_list_dir_shallow(self) -> None:
        """Non-recursive ``list_dir`` returns immediate child base names."""
        for name in ("a.txt", "b.txt"):
            await self.backend.write_file(
                os.path.join(self.temp_dir.name, name),
                b"x",
            )
        os.makedirs(os.path.join(self.temp_dir.name, "sub"))
        entries = await self.backend.list_dir(self.temp_dir.name)
        self.assertEqual(sorted(entries), ["a.txt", "b.txt", "sub"])

    async def test_list_dir_recursive(self) -> None:
        """Recursive ``list_dir`` returns file paths underneath the root."""
        await self.backend.write_file(
            os.path.join(self.temp_dir.name, "top.txt"),
            b"x",
        )
        await self.backend.write_file(
            os.path.join(self.temp_dir.name, "sub", "nested.txt"),
            b"x",
        )
        entries = await self.backend.list_dir(
            self.temp_dir.name,
            recursive=True,
        )
        basenames = sorted(os.path.basename(e) for e in entries)
        self.assertEqual(basenames, ["nested.txt", "top.txt"])

    async def test_stat_mtime(self) -> None:
        """``stat_mtime`` returns a float for an existing path, None else."""
        path = os.path.join(self.temp_dir.name, "f.txt")
        await self.backend.write_file(path, b"x")
        mtime = await self.backend.stat_mtime(path)
        self.assertIsInstance(mtime, float)
        self.assertIsNone(
            await self.backend.stat_mtime(
                os.path.join(self.temp_dir.name, "missing"),
            ),
        )

    async def test_delete_path_file(self) -> None:
        """``delete_path`` removes a single file."""
        path = os.path.join(self.temp_dir.name, "f.txt")
        await self.backend.write_file(path, b"x")
        await self.backend.delete_path(path)
        self.assertFalse(os.path.exists(path))

    async def test_delete_path_tree(self) -> None:
        """``delete_path`` removes a directory tree recursively."""
        nested = os.path.join(self.temp_dir.name, "d", "e")
        os.makedirs(nested)
        await self.backend.write_file(
            os.path.join(nested, "f.txt"),
            b"x",
        )
        target = os.path.join(self.temp_dir.name, "d")
        await self.backend.delete_path(target)
        self.assertFalse(os.path.exists(target))

    async def test_delete_path_missing_is_noop(self) -> None:
        """Deleting a non-existent path is a silent no-op (like rm -rf)."""
        # Must not raise.
        await self.backend.delete_path(
            os.path.join(self.temp_dir.name, "missing"),
        )


@unittest.skipIf(

    _IS_WINDOWS,

    "POSIX shell (/bin/sh) is not available on Windows",

)
class TestLocalBackendShellWrapping(IsolatedAsyncioTestCase):
    """Cases that explicitly use ``sh -c`` (POSIX-only, skipped on Windows).



    The backend primitive never invokes a shell itself; these tests cover

    the documented escape hatch where a *caller* wraps a command line as

    ``["/bin/sh", "-c", line]`` to use pipes / redirects / ``&&``.

    """

    async def asyncSetUp(self) -> None:
        """Build a backend per test."""
        self.backend = LocalBackend()

    async def test_sh_c_pipeline(self) -> None:
        """A piped command line runs when wrapped in ``sh -c``."""
        result = await self.backend.exec_shell(
            ["/bin/sh", "-c", "printf 'a\\nb\\na\\n' | sort | uniq"],
        )
        self.assertTrue(result.ok())
        self.assertEqual(result.stdout.decode().split(), ["a", "b"])

    async def test_sh_c_and_chain(self) -> None:
        """``&&`` chaining works through ``sh -c``."""
        result = await self.backend.exec_shell(
            ["/bin/sh", "-c", "true && echo chained"],
        )
        self.assertTrue(result.ok())
        self.assertEqual(result.stdout.decode().strip(), "chained")


if __name__ == "__main__":
    unittest.main()