answer42 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_1c/runtime.py ADDED
@@ -0,0 +1,636 @@
1
+ """1C test infrastructure launchers for the MCP bridge.
2
+
3
+ This module owns:
4
+ * file infobase creation via `ibcmd infobase create`
5
+ * .cf configuration loading (`ibcmd config load`)
6
+ * the autonomous 1C server (`ibsrv`) HTTP-gate lifecycle
7
+ * thin-client (`1cv8c /WS ws://host:port`) launch in /TESTMANAGER mode
8
+ * test-manager (thin client, `/TESTMANAGER`) launch and log wiring
9
+ * test-client (`/TESTCLIENT`) argv construction and Python-side launch
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from . import os_support
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+ DEFAULT_ONEC_VERSION = "8.3.27.1606"
30
+ DEFAULT_WS_PORT = 8765
31
+ DEFAULT_IBSRV_HTTP_PORT = 8314
32
+ DEFAULT_TEST_CLIENT_PORT = 1538
33
+ DEFAULT_IBSRV_DIRECT_PORT = 1541
34
+ DEFAULT_DISPLAY_START = 99
35
+ DEFAULT_DISPLAY_RES = "1280x1024x24"
36
+ DEFAULT_PLATFORM_DIR = f"/opt/1cv8/x86_64/{DEFAULT_ONEC_VERSION}"
37
+ DEFAULT_THICK_CLIENT = f"{DEFAULT_PLATFORM_DIR}/1cv8"
38
+ DEFAULT_THIN_CLIENT = f"{DEFAULT_PLATFORM_DIR}/1cv8c"
39
+ DEFAULT_IBCMD = f"{DEFAULT_PLATFORM_DIR}/ibcmd"
40
+ DEFAULT_IBSRV = f"{DEFAULT_PLATFORM_DIR}/ibsrv"
41
+ DEFAULT_DATA_DIR = str(os_support.default_data_dir())
42
+ DEFAULT_IB_PATH = str(os_support.default_data_dir() / "ib")
43
+ DEFAULT_IBSRV_DATA = str(os_support.default_data_dir() / "ibsrv-data")
44
+ DEFAULT_CF_BUILT = "build/MCPTestManager.cf"
45
+ DEFAULT_CLIENT_CF_BUILT = "build/MCPTestClient.cf"
46
+
47
+
48
+ def _default_inbox_dir() -> Path:
49
+ return os_support.default_data_dir()
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class InfobaseSpec:
54
+ """File infobase used by the test-manager and the ibsrv HTTP-gate."""
55
+
56
+ db_path: Path
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class StandaloneServerSpec:
61
+ """Standalone 1C server (ibsrv) HTTP-gate connection parameters.
62
+
63
+ ibsrv publishes a single TCP port that the thin client reaches via
64
+ `/WS"http://host:port"` (note: the *http://* form, not `ws://`). The
65
+ URL must NOT carry a trailing slash; the HTTP-base path is configured
66
+ inside ibsrv itself.
67
+
68
+ The standalone server requires that the infobase it serves has a
69
+ stable name (`--name=`) — the thin client must refer to the same
70
+ name when connecting.
71
+ """
72
+
73
+ address: str = "127.0.0.1"
74
+ port: int = DEFAULT_IBSRV_HTTP_PORT
75
+ name: str = "mcp"
76
+ data_dir: Path = field(default_factory=lambda: Path(DEFAULT_IBSRV_DATA))
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class TestManagerSpec:
81
+ """Test-manager (thin client) connection to the standalone server."""
82
+
83
+ onec_path: str = DEFAULT_THIN_CLIENT
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class ManagerRuntime:
88
+ """Tracks the live processes owned by the MCP server."""
89
+
90
+ infobase: InfobaseSpec
91
+ server: StandaloneServerSpec
92
+ manager: TestManagerSpec
93
+ server_process: subprocess.Popen | None = None
94
+ manager_process: subprocess.Popen | None = None
95
+ log_dir: Path = field(default_factory=lambda: _default_inbox_dir() / "logs")
96
+ extra: dict[str, Any] = field(default_factory=dict)
97
+
98
+
99
+ def find_free_display(start: int = DEFAULT_DISPLAY_START) -> str:
100
+ """Find the first free Xvfb display starting from :99."""
101
+ for n in range(start, 200):
102
+ path = f"/tmp/.X11-unix/X{n}"
103
+ if not os.path.exists(path):
104
+ return f":{n}"
105
+ raise RuntimeError("No free X display found")
106
+
107
+
108
+ def find_free_port(start: int = DEFAULT_WS_PORT, end: int | None = None) -> int:
109
+ """Find a free TCP port in the range start..start+500 (or start..end)."""
110
+ end = end or start + 500
111
+ for port in range(start, end):
112
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
113
+ try:
114
+ s.bind(("", port))
115
+ return port
116
+ except OSError:
117
+ continue
118
+ raise RuntimeError(f"No free port found in range {start}..{end}")
119
+
120
+
121
+ def start_xvfb(display: str, resolution: str = DEFAULT_DISPLAY_RES) -> subprocess.Popen:
122
+ """Start Xvfb for the given display and return the Popen handle."""
123
+ if os_support.IS_WINDOWS:
124
+ raise RuntimeError("Xvfb is not available on Windows")
125
+ xvfb_bin = shutil.which("Xvfb") or "/usr/bin/Xvfb"
126
+ if not Path(xvfb_bin).exists():
127
+ raise FileNotFoundError("Xvfb not found; install it to run headless sessions")
128
+ proc = subprocess.Popen(
129
+ [xvfb_bin, display, "-screen", "0", resolution, "-ac", "+extension", "GLX", "+render", "-noreset"],
130
+ stdout=subprocess.DEVNULL,
131
+ stderr=subprocess.DEVNULL,
132
+ stdin=subprocess.DEVNULL,
133
+ **os_support.subprocess_creation_kwargs(),
134
+ )
135
+ # Give Xvfb a moment; caller should poll DISPLAY if needed.
136
+ time.sleep(0.5)
137
+ LOGGER.info("Xvfb started on display %s (pid=%s)", display, proc.pid)
138
+ return proc
139
+
140
+
141
+ def stop_xvfb(proc: subprocess.Popen | None) -> None:
142
+ """Stop the Xvfb process started by start_xvfb."""
143
+ kill_process(proc, "Xvfb")
144
+
145
+
146
+ def is_port_listening(host: str, port: int, timeout: float = 1.0) -> bool:
147
+ """Return True if something accepts TCP connections on host:port."""
148
+ try:
149
+ with socket.create_connection((host, port), timeout=timeout):
150
+ return True
151
+ except (OSError, socket.timeout):
152
+ return False
153
+
154
+
155
+ def wait_for_port(host: str, port: int, timeout: float = 30.0, poll: float = 0.5) -> bool:
156
+ """Block until host:port accepts TCP connections or timeout expires."""
157
+ deadline = time.monotonic() + timeout
158
+ while time.monotonic() < deadline:
159
+ if is_port_listening(host, port):
160
+ return True
161
+ time.sleep(poll)
162
+ return False
163
+
164
+
165
+ def kill_process(proc: subprocess.Popen | None, label: str) -> None:
166
+ """Terminate a subprocess tree, escalating when needed."""
167
+ os_support.kill_process_tree(proc, label, LOGGER)
168
+
169
+
170
+ def _decode_process_output(data: bytes) -> str:
171
+ encodings = ["utf-8-sig", "utf-8", "cp866", "cp1251"] if os_support.IS_WINDOWS else ["utf-8-sig", "utf-8", "cp866"]
172
+ for encoding in encodings:
173
+ try:
174
+ return data.decode(encoding)
175
+ except UnicodeDecodeError:
176
+ continue
177
+ return data.decode(encodings[-1], errors="replace")
178
+
179
+
180
+ def _run_capture(argv: list[str], *, timeout: float, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
181
+ input_bytes = input_text.encode("utf-8") if input_text is not None else None
182
+ completed = subprocess.run(argv, input=input_bytes, capture_output=True, text=False, timeout=timeout)
183
+ return subprocess.CompletedProcess(
184
+ args=completed.args,
185
+ returncode=completed.returncode,
186
+ stdout=_decode_process_output(completed.stdout or b""),
187
+ stderr=_decode_process_output(completed.stderr or b""),
188
+ )
189
+
190
+
191
+ def create_file_infobase(
192
+ ibcmd: str,
193
+ db_path: Path,
194
+ timeout: float = 60.0,
195
+ ) -> None:
196
+ """Create a fresh file infobase via `ibcmd infobase create --create-database`.
197
+
198
+ Wipes any existing directory at db_path first. The DB is created
199
+ without a configuration — a .cf is loaded into it next via
200
+ `load_configuration`.
201
+ """
202
+ if db_path.exists():
203
+ shutil.rmtree(db_path, ignore_errors=True)
204
+ db_path.parent.mkdir(parents=True, exist_ok=True)
205
+ argv = [
206
+ ibcmd,
207
+ "infobase",
208
+ "create",
209
+ "--db-path=" + str(db_path),
210
+ "--create-database",
211
+ ]
212
+ LOGGER.info("Creating file infobase at %s via %s", db_path, ibcmd)
213
+ completed = _run_capture(argv, timeout=timeout)
214
+ if completed.returncode != 0:
215
+ raise RuntimeError(
216
+ f"ibcmd infobase create failed (rc={completed.returncode}): "
217
+ f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
218
+ )
219
+ LOGGER.info("File infobase created: %s", db_path)
220
+
221
+
222
+ def load_configuration(
223
+ ibcmd: str,
224
+ db_path: Path,
225
+ cf_path: str | Path,
226
+ timeout: float = 120.0,
227
+ ) -> None:
228
+ """Load a `.cf` configuration into an existing file infobase.
229
+
230
+ The default flow uses `MCPTestManager.cf` which embeds the MCP
231
+ processor in the configuration.
232
+ """
233
+ if not Path(cf_path).exists():
234
+ raise FileNotFoundError(f"Configuration file not found: {cf_path}")
235
+ argv = [
236
+ ibcmd,
237
+ "config",
238
+ "load",
239
+ f"--db-path={db_path}",
240
+ str(cf_path),
241
+ ]
242
+ LOGGER.info("Loading configuration %s into %s", cf_path, db_path)
243
+ completed = _run_capture(argv, timeout=timeout)
244
+ if completed.returncode != 0:
245
+ raise RuntimeError(
246
+ f"ibcmd config load failed (rc={completed.returncode}): "
247
+ f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
248
+ )
249
+ LOGGER.info("Configuration loaded from %s into %s", cf_path, db_path)
250
+ apply_configuration(ibcmd=ibcmd, db_path=db_path, timeout=timeout)
251
+
252
+
253
+ def apply_configuration(ibcmd: str, db_path: Path, timeout: float = 120.0) -> None:
254
+ """Apply the loaded configuration to the infobase database configuration."""
255
+ argv = [
256
+ ibcmd,
257
+ "config",
258
+ "apply",
259
+ "--db-path=" + str(db_path),
260
+ "--force",
261
+ "--dynamic=force",
262
+ "--session-terminate=force",
263
+ ]
264
+ LOGGER.info("Applying configuration to %s", db_path)
265
+ completed = _run_capture(argv, input_text="3\n", timeout=timeout)
266
+ if completed.returncode != 0:
267
+ raise RuntimeError(
268
+ f"ibcmd config apply failed (rc={completed.returncode}): "
269
+ f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
270
+ )
271
+ LOGGER.info("Configuration applied to %s", db_path)
272
+
273
+
274
+ def ensure_standalone_config(
275
+ data_dir: Path,
276
+ db_path: Path,
277
+ address: str,
278
+ port: int,
279
+ name: str,
280
+ ) -> Path:
281
+ """Write a minimal standalone-server YAML config for the MCP infobase."""
282
+ data_dir.mkdir(parents=True, exist_ok=True)
283
+ config_path = data_dir / "standalone.yaml"
284
+ config_path.write_text(
285
+ "server:\n"
286
+ f" address: {address}\n"
287
+ f" port: {port}\n"
288
+ "database:\n"
289
+ f" path: {db_path}\n"
290
+ "infobase:\n"
291
+ f" name: {name}\n"
292
+ "http:\n"
293
+ " base: /\n",
294
+ encoding="utf-8",
295
+ )
296
+ return config_path
297
+
298
+
299
+ def start_standalone_server(
300
+ ibsrv: str,
301
+ address: str,
302
+ port: int,
303
+ name: str,
304
+ data_dir: Path,
305
+ log_dir: Path,
306
+ db_path: Path | None = None,
307
+ timeout: float = 30.0,
308
+ direct_regport: int | None = None,
309
+ direct_range: tuple[int, int] | None = None,
310
+ ) -> subprocess.Popen:
311
+ """Start the autonomous 1C server (ibsrv) and wait for the HTTP-gate.
312
+
313
+ `direct_regport` overrides the direct-connect gateway port (default
314
+ 1541 for current platform builds). When running two ibsrv instances on the same
315
+ host, each must use a different direct-regport.
316
+
317
+ `direct_range` must also be unique for concurrently running autonomous
318
+ servers. The platform default 1560:1591 is shared, and otherwise several
319
+ ibsrv processes can fail with "Ошибка открытия порта '156x' шлюза
320
+ Конфигуратора" while the HTTP port itself looks free.
321
+ """
322
+ if not Path(ibsrv).exists():
323
+ raise FileNotFoundError(f"ibsrv not found: {ibsrv}")
324
+ if data_dir.exists():
325
+ shutil.rmtree(data_dir)
326
+ data_dir.mkdir(parents=True, exist_ok=True)
327
+ log_dir.mkdir(parents=True, exist_ok=True)
328
+ log_path = log_dir / "ibsrv.log"
329
+ log_handle = open(log_path, "ab", buffering=0)
330
+ LOGGER.info("Starting ibsrv (data=%s, name=%s, address=%s, port=%s, direct_regport=%s)", data_dir, name, address, port, direct_regport or "default")
331
+ config_path = ensure_standalone_config(
332
+ data_dir=data_dir,
333
+ db_path=db_path or Path(DEFAULT_IB_PATH),
334
+ address=address,
335
+ port=port,
336
+ name=name,
337
+ )
338
+ argv = [
339
+ ibsrv,
340
+ f"--data={data_dir}",
341
+ f"--config={config_path}",
342
+ f"--name={name}",
343
+ ]
344
+ if direct_regport is not None:
345
+ argv.append(f"--direct-regport={direct_regport}")
346
+ if direct_range is not None:
347
+ argv.append(f"--direct-range={direct_range[0]}:{direct_range[1]}")
348
+ proc = subprocess.Popen(
349
+ argv,
350
+ stdout=log_handle,
351
+ stderr=log_handle,
352
+ stdin=subprocess.DEVNULL,
353
+ **os_support.subprocess_creation_kwargs(),
354
+ )
355
+ if not wait_for_port(address, port, timeout=timeout):
356
+ kill_process(proc, "ibsrv")
357
+ raise RuntimeError(
358
+ f"ibsrv did not start listening on {address}:{port} within {timeout}s. "
359
+ f"See log: {log_path}"
360
+ )
361
+ LOGGER.info("ibsrv is listening on %s:%s (pid=%s, name=%s)", address, port, proc.pid, name)
362
+ return proc
363
+
364
+
365
+ def export_eventlog(
366
+ ibcmd: str,
367
+ eventlog_dir: Path,
368
+ out_path: Path,
369
+ from_time: str | None = None,
370
+ to_time: str | None = None,
371
+ fmt: str = "json",
372
+ timeout: float = 30.0,
373
+ ) -> Path:
374
+ """Export the 1C autonomous-server event log to a file.
375
+
376
+ The `ibcmd eventlog export` command works directly with the
377
+ event-log directory and does not connect to the infobase. This is
378
+ the preferred diagnostic path for manager/client startup errors in
379
+ the autonomous-server scenario.
380
+ """
381
+ if not Path(ibcmd).exists():
382
+ raise FileNotFoundError(f"ibcmd not found: {ibcmd}")
383
+ out_path.parent.mkdir(parents=True, exist_ok=True)
384
+ argv = [
385
+ ibcmd,
386
+ "eventlog",
387
+ "export",
388
+ f"--format={fmt}",
389
+ f"--out={out_path}",
390
+ ]
391
+ if from_time:
392
+ argv.append(f"--from={from_time}")
393
+ if to_time:
394
+ argv.append(f"--to={to_time}")
395
+ argv.append(str(eventlog_dir))
396
+ LOGGER.info("Exporting 1C event log: %s", " ".join(argv))
397
+ completed = _run_capture(argv, timeout=timeout)
398
+ if completed.returncode != 0:
399
+ raise RuntimeError(
400
+ f"ibcmd eventlog export failed (rc={completed.returncode}): "
401
+ f"stdout={completed.stdout!r} stderr={completed.stderr!r}"
402
+ )
403
+ return out_path
404
+
405
+
406
+ def build_thin_client_ws_url(address: str, port: int) -> str:
407
+ """Build the `/WS` URL value for `1cv8c` against the ibsrv HTTP-gate.
408
+
409
+ Return the raw URL without embedded quotes. Callers pass it as a separate
410
+ argv element after `/WS`; this avoids Windows `subprocess.list2cmdline`
411
+ escaping embedded quotes as `/WS\"http://...\"`, which 1C can parse as
412
+ invalid connection parameters.
413
+ """
414
+ return f"http://{address}:{port}"
415
+
416
+
417
+ def build_thin_test_manager_argv(
418
+ onec_path: str,
419
+ ws_url: str,
420
+ websocket_url: str,
421
+ ) -> list[str]:
422
+ """Compose argv for 1cv8c /WS <ws-url> /TESTMANAGER /C <ws-bridge>.
423
+
424
+ The MCP test-manager infobase is intentionally built without any
425
+ user accounts (see the configuration's application module), so
426
+ `/N`/`/P` are not passed: the thin client starts directly in the
427
+ test-manager form. The MCP processor is opened automatically by
428
+ the application module's `ПриНачалеРаботыСистемы` handler.
429
+
430
+ `/Execute` is intentionally not used here — the processor lives
431
+ inside the configuration and is started by the application module
432
+ rather than by the client command line.
433
+ """
434
+ return [
435
+ onec_path,
436
+ "ENTERPRISE",
437
+ "/DisableStartupMessages",
438
+ "/DisableStartupDialogs",
439
+ "/WS",
440
+ ws_url,
441
+ "/Lru",
442
+ "/VLru_RU",
443
+ "/TESTMANAGER",
444
+ "/C",
445
+ websocket_url,
446
+ ]
447
+
448
+
449
+ def build_thin_test_manager_file_argv(
450
+ onec_path: str,
451
+ db_path: Path,
452
+ websocket_url: str,
453
+ ) -> list[str]:
454
+ """Compose argv for 1cv8c /F <db-path> /TESTMANAGER /C <ws-bridge>."""
455
+ return [
456
+ onec_path,
457
+ "ENTERPRISE",
458
+ "/DisableStartupMessages",
459
+ "/DisableStartupDialogs",
460
+ "/F",
461
+ str(db_path),
462
+ "/Lru",
463
+ "/VLru_RU",
464
+ "/TESTMANAGER",
465
+ "/C",
466
+ websocket_url,
467
+ ]
468
+
469
+
470
+ def add_1c_out_args_before_testmanager(argv: list[str], onec_out_path: Path | str) -> list[str]:
471
+ """Add `/Out <path>` before `/TESTMANAGER` so it is never consumed by `/C`."""
472
+ startup_args = ["/DisableUnrecoverableErrorMessage", "/Out", str(onec_out_path)]
473
+ try:
474
+ testmanager_index = argv.index("/TESTMANAGER")
475
+ except ValueError:
476
+ return [*argv, *startup_args]
477
+ return [*argv[:testmanager_index], *startup_args, *argv[testmanager_index:]]
478
+
479
+
480
+ def _start_thin_test_manager_argv(
481
+ argv: list[str],
482
+ log_dir: Path,
483
+ display: str = "",
484
+ log_name: str = "test_manager.log",
485
+ ) -> subprocess.Popen:
486
+ if not Path(argv[0]).exists():
487
+ raise FileNotFoundError(f"1cv8c (thin) not found: {argv[0]}")
488
+ log_dir.mkdir(parents=True, exist_ok=True)
489
+ log_path = log_dir / log_name
490
+ onec_out_path = log_dir / f"{Path(log_name).stem}_1c_out.log"
491
+ argv = add_1c_out_args_before_testmanager(argv, onec_out_path)
492
+ log_handle = open(log_path, "ab", buffering=0)
493
+ LOGGER.info("Starting thin test manager: %s", " ".join(argv))
494
+ env = os.environ.copy()
495
+ if display:
496
+ env["DISPLAY"] = display
497
+ proc = subprocess.Popen(
498
+ argv,
499
+ stdout=log_handle,
500
+ stderr=log_handle,
501
+ stdin=subprocess.DEVNULL,
502
+ **os_support.subprocess_creation_kwargs(),
503
+ env=env,
504
+ )
505
+ proc.answer42_stdout_log_path = str(log_path) # type: ignore[attr-defined]
506
+ proc.answer42_out_log_path = str(onec_out_path) # type: ignore[attr-defined]
507
+ proc.answer42_argv = argv # type: ignore[attr-defined]
508
+ LOGGER.info("Test manager launched (pid=%s); log=%s out=%s", proc.pid, log_path, onec_out_path)
509
+ return proc
510
+
511
+
512
+ def start_thin_test_manager(
513
+ onec_path: str,
514
+ ws_url: str,
515
+ websocket_url: str,
516
+ log_dir: Path,
517
+ display: str = ":99",
518
+ ) -> subprocess.Popen:
519
+ """Start the 1C test-manager (thin client, /WS HTTP gate) and return Popen."""
520
+ argv = build_thin_test_manager_argv(onec_path, ws_url, websocket_url)
521
+ return _start_thin_test_manager_argv(argv, log_dir=log_dir, display=display)
522
+
523
+
524
+ def start_thin_test_manager_file_direct(
525
+ onec_path: str,
526
+ db_path: Path,
527
+ websocket_url: str,
528
+ log_dir: Path,
529
+ display: str = "",
530
+ ) -> subprocess.Popen:
531
+ """Start the 1C test-manager directly against a file infobase via /F."""
532
+ argv = build_thin_test_manager_file_argv(onec_path, db_path, websocket_url)
533
+ return _start_thin_test_manager_argv(argv, log_dir=log_dir, display=display)
534
+
535
+
536
+ def _split_extra_args(value: str) -> list[str]:
537
+ value = (value or "").strip()
538
+ if not value:
539
+ return []
540
+ try:
541
+ parts = shlex.split(value, posix=not os_support.IS_WINDOWS)
542
+ except ValueError:
543
+ parts = value.split()
544
+ return [part[1:-1] if len(part) >= 2 and part[0] == part[-1] == '"' else part for part in parts]
545
+
546
+
547
+ def _merge_command_parameter(argv: list[str], command_parameter: str) -> list[str]:
548
+ command_parameter = (command_parameter or "").strip()
549
+ if not command_parameter:
550
+ return argv
551
+ lowered = [item.lower() for item in argv]
552
+ if "/c" in lowered:
553
+ index = lowered.index("/c")
554
+ if index + 1 < len(argv):
555
+ argv[index + 1] = f"{argv[index + 1]};{command_parameter}"
556
+ else:
557
+ argv.append(command_parameter)
558
+ return argv
559
+ return [*argv, "/C", command_parameter]
560
+
561
+
562
+ def build_test_client_argv(
563
+ onec_path: str,
564
+ *,
565
+ connection_mode: str,
566
+ connection_value: str,
567
+ port: int,
568
+ username: str = "",
569
+ password: str = "",
570
+ extra_args: str = "",
571
+ execute: str = "",
572
+ command_parameter: str = "",
573
+ out_log: Path | str | None = None,
574
+ ) -> list[str]:
575
+ """Compose argv for 1cv8c /TESTCLIENT without going through a shell."""
576
+ mode = connection_mode.lower()
577
+ if mode in {"ws", "http", "url"}:
578
+ connection_arg = "/WS"
579
+ elif mode == "server":
580
+ connection_arg = "/S"
581
+ elif mode in {"file-direct", "direct-file", "file_direct", "direct_file"}:
582
+ connection_arg = "/F"
583
+ else:
584
+ raise ValueError(f"Unsupported test-client connection mode: {connection_mode!r}")
585
+
586
+ argv = [
587
+ onec_path,
588
+ "ENTERPRISE",
589
+ connection_arg,
590
+ connection_value,
591
+ "/Lru",
592
+ "/VLru_RU",
593
+ "/DisableStartupMessages",
594
+ "/DisableStartupDialogs",
595
+ "/DisableUnrecoverableErrorMessage",
596
+ ]
597
+ if out_log:
598
+ argv.extend(["/Out", str(out_log)])
599
+ argv.extend(_split_extra_args(extra_args))
600
+ if execute:
601
+ argv.extend(["/Execute", execute])
602
+ argv = _merge_command_parameter(argv, command_parameter)
603
+ argv.extend(["/TESTCLIENT", f"-TPort{port}"])
604
+ if username:
605
+ argv.extend(["/N", username, "/P", password])
606
+ return argv
607
+
608
+
609
+ def start_test_client(
610
+ argv: list[str],
611
+ log_dir: Path,
612
+ display: str = "",
613
+ log_name: str = "test_client_stdout.log",
614
+ ) -> subprocess.Popen:
615
+ """Start 1C /TESTCLIENT from Python so argv quoting and PID are controlled."""
616
+ if not Path(argv[0]).exists():
617
+ raise FileNotFoundError(f"1cv8c (thin) not found: {argv[0]}")
618
+ log_dir.mkdir(parents=True, exist_ok=True)
619
+ stdout_log_path = log_dir / log_name
620
+ log_handle = open(stdout_log_path, "ab", buffering=0)
621
+ LOGGER.info("Starting test client: %s", " ".join(argv))
622
+ env = os.environ.copy()
623
+ if display:
624
+ env["DISPLAY"] = display
625
+ proc = subprocess.Popen(
626
+ argv,
627
+ stdout=log_handle,
628
+ stderr=log_handle,
629
+ stdin=subprocess.DEVNULL,
630
+ **os_support.subprocess_creation_kwargs(),
631
+ env=env,
632
+ )
633
+ proc.answer42_stdout_log_path = str(stdout_log_path) # type: ignore[attr-defined]
634
+ proc.answer42_argv = argv # type: ignore[attr-defined]
635
+ LOGGER.info("Test client launched (pid=%s); stdout=%s", proc.pid, stdout_log_path)
636
+ return proc