diff --git a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs index a1ee50e77..a5948a1bf 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessContext.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessContext.cs @@ -29,6 +29,8 @@ public class ProcessContext : AutoContext, IDisposable private StdTextReadStream _stdOutContext; private StdTextReadStream _stdErrContext; private StdTextWriteStream _stdInContext; + private ProcessOutputWrapper _stdOutWrapper; + private ProcessOutputWrapper _stdErrWrapper; private readonly IValue _outputEncoding; @@ -107,15 +109,14 @@ public void Start() if (IsOutputRedirected) { - var stream = new ProcessOutputWrapper(_p, ProcessOutputWrapper.OutputVariant.Stdout); - stream.StartReading(); - _stdOutContext = new StdTextReadStream(stream); + _stdOutWrapper = new ProcessOutputWrapper(_p.StandardOutput); + _stdOutWrapper.StartReading(); + _stdOutContext = new StdTextReadStream(_stdOutWrapper); - stream = new ProcessOutputWrapper(_p, ProcessOutputWrapper.OutputVariant.Stderr); - stream.StartReading(); - - _stdErrContext = new StdTextReadStream(stream); + _stdErrWrapper = new ProcessOutputWrapper(_p.StandardError); + _stdErrWrapper.StartReading(); + _stdErrContext = new StdTextReadStream(_stdErrWrapper); } } @@ -149,7 +150,14 @@ public bool HasExited { get { - return _p.HasExited; + if (!_p.HasExited) + return false; + + // Процесс завершился, но фоновое чтение могло еще не добрать + // хвост вывода из пайпа: цикл "Пока НЕ Завершен ИЛИ ЕстьДанные" + // не должен завершиться раньше, чем вывод станет доступен + return (_stdOutWrapper?.IsDrained ?? true) + && (_stdErrWrapper?.IsDrained ?? true); } } @@ -176,6 +184,12 @@ public bool WaitForExit(IValue timeout = null) if (timeout == null) { _p.WaitForExit(); + + // Прочитать() после ожидания должен видеть весь вывод: + // дожидаемся, пока фоновое чтение доберет хвост потоков + _stdOutWrapper?.WaitSourceDrained(); + _stdErrWrapper?.WaitSourceDrained(); + return true; } diff --git a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs index afcd9800a..7b800e6a8 100644 --- a/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs +++ b/src/OneScript.StandardLibrary/Processes/ProcessOutputWrapper.cs @@ -1,39 +1,57 @@ -/*---------------------------------------------------------- -This Source Code Form is subject to the terms of the -Mozilla Public License, v.2.0. If a copy of the MPL -was not distributed with this file, You can obtain one +/*---------------------------------------------------------- +This Source Code Form is subject to the terms of the +Mozilla Public License, v.2.0. If a copy of the MPL +was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. ----------------------------------------------------------*/ using System; using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Text; -using sys = System.Diagnostics; +using System.Threading.Tasks; + +[assembly: InternalsVisibleTo("OneScript.StandardLibrary.Tests")] namespace OneScript.StandardLibrary.Processes { + /// + /// Неблокирующий читатель поверх потока вывода процесса + /// (Process.StandardOutput/StandardError). Фоновая задача перекачивает + /// сырые символы источника в буфер, включая настоящие переводы строк, + /// записанные процессом. + /// Событийный API (BeginOutputReadLine) не используется: он вырезает + /// терминаторы строк, и их обратный синтез порождал гонку (issue #1726), + /// а вывод, не завершенный переводом строки, застревал в .NET до конца процесса. + /// class ProcessOutputWrapper : TextReader { - private readonly sys.Process _process; - private readonly OutputVariant _variant; + private const int CompactionThreshold = 4096; + + private readonly TextReader _source; private readonly StringBuilder _buffer = new StringBuilder(4096); private int _bufferIndex = 0; - private bool AlreadyReading { get; set; } + // Позиция, до которой ReadLine уже искал терминатор и не нашел: + // диапазон [_bufferIndex, _lineScanIndex) заведомо без терминаторов, + // повторные вызовы сканируют только новые символы, а не буфер целиком + private int _lineScanIndex = 0; - private Encoding Encoding { get; set; } + // Пишутся пампом и читаются читателями только под lock(_buffer) + private bool _streamEnded; + private Exception _pumpError; - public enum OutputVariant - { - Stdout, - Stderr - } + private volatile bool _stopRequested; + + private Task _pumpTask; + + private bool AlreadyReading { get; set; } - public ProcessOutputWrapper(sys.Process process, OutputVariant variant) + public ProcessOutputWrapper(TextReader source) { - _process = process; - _variant = variant; + _source = source; } public void StartReading() @@ -41,44 +59,87 @@ public void StartReading() if (AlreadyReading) return; - if (_variant == OutputVariant.Stdout) - { - Encoding = _process.StartInfo.StandardOutputEncoding; - _process.OutputDataReceived += StreamDataReceived; - _process.BeginOutputReadLine(); - } - else - { - Encoding = _process.StartInfo.StandardErrorEncoding; - _process.ErrorDataReceived += StreamDataReceived; - _process.BeginErrorReadLine(); - } + _pumpTask = Task.Run(() => PumpAsync(_source)); AlreadyReading = true; } - private void StopReading() + private async Task PumpAsync(TextReader reader) { - if (_variant == OutputVariant.Stdout) + var chunk = new char[4096]; + try + { + while (true) + { + int read = await reader.ReadAsync(chunk, 0, chunk.Length).ConfigureAwait(false); + if (read == 0) + break; + + lock (_buffer) + { + // После закрытия читателя данные никому не нужны, но пайп + // продолжает дренироваться до конца потока: иначе процесс, + // заполнив пайп, навсегда заблокируется на записи + if (!_stopRequested) + _buffer.Append(chunk, 0, read); + } + } + } + catch (Exception e) { - _process.OutputDataReceived -= StreamDataReceived; + // Ошибка источника перебрасывается читателю, когда он вычитает + // накопленные данные (см. ThrowIfPumpFailed). После остановки + // чтения (Dispose) источник закрыт, его ошибки ожидаемы. + if (!_stopRequested) + { + lock (_buffer) + { + _pumpError = e; + } + } } - else + finally { - _process.ErrorDataReceived -= StreamDataReceived; + lock (_buffer) + { + _streamEnded = true; + } } } - private void StreamDataReceived(object sender, sys.DataReceivedEventArgs e) + // Ошибка чтения источника отдается после уже накопленных данных, + // поэтому перебрасывается только при пустом буфере. + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private void ThrowIfPumpFailed() + { + if (_pumpError != null && _bufferIndex >= _buffer.Length) + ExceptionDispatchInfo.Capture(_pumpError).Throw(); + } + + /// + /// Дождаться, пока весь вывод процесса будет перекачан в буфер. + /// Вызывается после Process.WaitForExit(), чтобы Прочитать() гарантированно + /// видел хвост вывода (аналог гарантии WaitForExit() для событийного чтения). + /// + internal void WaitSourceDrained() + { + _pumpTask?.Wait(); + } + + /// + /// Источник дочитан до конца: после завершения процесса хвост его + /// вывода гарантированно доступен читателям. + /// + internal bool IsDrained => _pumpTask == null || _pumpTask.IsCompleted; + + // Для тестов: физический размер внутреннего буфера + internal int InternalBufferSize { - if (e.Data != null) + get { - lock(_buffer) + lock (_buffer) { - if (_buffer.Length != 0) - _buffer.Append(System.Environment.NewLine); - - _buffer.Append(e.Data); + return _buffer.Length; } } } @@ -88,9 +149,12 @@ public override int Peek() lock (_buffer) { if (_bufferIndex >= _buffer.Length) + { + ThrowIfPumpFailed(); return -1; // no data + } - return _buffer[_bufferIndex]; + return _buffer[_bufferIndex]; } } @@ -98,7 +162,26 @@ public override int Read() { lock (_buffer) { - return ReadInternal(); + int ch = ReadInternal(); + if (ch == -1) + ThrowIfPumpFailed(); + + CompactBuffer(); + return ch; + } + } + + // Вычитанный префикс буфера периодически удаляется, иначе вывод + // долгоживущего процесса накапливается в памяти даже при аккуратном + // читателе. + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private void CompactBuffer() + { + if (_bufferIndex >= CompactionThreshold) + { + _buffer.Remove(0, _bufferIndex); + _lineScanIndex = Math.Max(0, _lineScanIndex - _bufferIndex); + _bufferIndex = 0; } } @@ -123,7 +206,7 @@ public override int Read(char[] destBuffer, int index, int count) if (destBuffer.Length - index < count) throw new ArgumentException("Invalid offset"); - + int n = 0; lock (_buffer) { @@ -133,42 +216,81 @@ public override int Read(char[] destBuffer, int index, int count) if (ch == -1) break; destBuffer[index + n++] = (char)ch; - } while (n < count); + } while (n < count); + + if (n == 0 && count > 0) + ThrowIfPumpFailed(); + + CompactBuffer(); } return n; } + /// + /// Возвращает очередную строку, только когда она гарантированно полна: + /// ее терминатор уже в буфере либо поток источника закончился. + /// Для незавершенной строки возвращает null (данных пока нет) — + /// частично записанная процессом строка не может быть возвращена + /// ни как строка, ни по кускам. + /// public override string ReadLine() { - var sb = new StringBuilder(); lock (_buffer) { - while (true) + for (int i = Math.Max(_lineScanIndex, _bufferIndex); i < _buffer.Length; i++) { - int ch = ReadInternal(); - if (ch == -1) break; - if (ch == '\r' || ch == '\n') + char ch = _buffer[i]; + + if (ch == '\n') + return ConsumeLine(i, i + 1); + + if (ch == '\r') { - if (ch == '\r' && Peek() == '\n') Read(); - return sb.ToString(); + if (i + 1 < _buffer.Length) + return ConsumeLine(i, _buffer[i + 1] == '\n' ? i + 2 : i + 1); + + if (_streamEnded) + return ConsumeLine(i, i + 1); + + // '\r' — последний символ буфера, а поток еще жив: + // парный '\n' может быть в пути, ждем следующей порции + // (сам '\r' при этом остается несканированным) + _lineScanIndex = i; + return null; } - sb.Append((char)ch); - } + } + + _lineScanIndex = _buffer.Length; + + // терминатора нет; после конца потока остаток буфера — последняя строка + if (_streamEnded && _bufferIndex < _buffer.Length) + return ConsumeLine(_buffer.Length, _buffer.Length); + + ThrowIfPumpFailed(); + return null; } - if (sb.Length > 0) - return sb.ToString(); + } - return null; + // должна вызываться ТОЛЬКО внутри вышестоящего блока lock. + private string ConsumeLine(int lineEnd, int nextPosition) + { + var line = _buffer.ToString(_bufferIndex, lineEnd - _bufferIndex); + _bufferIndex = nextPosition; + _lineScanIndex = nextPosition; + CompactBuffer(); + return line; } public override string ReadToEnd() { lock (_buffer) { - string data = base.ReadToEnd(); + ThrowIfPumpFailed(); + + string data = _buffer.ToString(_bufferIndex, _buffer.Length - _bufferIndex); ResetBuffer(); - return data; + return data; } } @@ -176,13 +298,17 @@ private void ResetBuffer() { _buffer.Clear(); _bufferIndex = 0; + _lineScanIndex = 0; } protected override void Dispose(bool disposing) { if (disposing) { - StopReading(); + lock (_buffer) + { + _stopRequested = true; + } } base.Dispose(disposing); diff --git a/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs new file mode 100644 index 000000000..64b15a4f5 --- /dev/null +++ b/src/Tests/OneScript.StandardLibrary.Tests/ProcessOutputWrapperTest.cs @@ -0,0 +1,296 @@ +/*---------------------------------------------------------- +This Source Code Form is subject to the terms of the +Mozilla Public License, v.2.0. If a copy of the MPL +was not distributed with this file, You can obtain one +at http://mozilla.org/MPL/2.0/. +----------------------------------------------------------*/ + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Xunit; +using FluentAssertions; +using OneScript.StandardLibrary.Processes; + +namespace OneScript.StandardLibrary.Tests +{ + public class ProcessOutputWrapperTest : IDisposable + { + // Источник, отдающий текст порциями, как пайп работающего процесса: + // Read блокируется до появления следующей порции, Complete() дает EOF. + private class ChunkedReader : TextReader + { + private readonly BlockingCollection _chunks = new BlockingCollection(); + private string _current = string.Empty; + private int _pos; + private Exception _error; + + public void Push(string chunk) => _chunks.Add(chunk); + + public void Complete() => _chunks.CompleteAdding(); + + public void Fail(Exception error) + { + _error = error; + _chunks.CompleteAdding(); + } + + public bool AllTaken => _chunks.Count == 0; + + public override int Read(char[] buffer, int index, int count) + { + while (_pos >= _current.Length) + { + if (!_chunks.TryTake(out _current, Timeout.Infinite)) + { + if (_error != null) + throw _error; + + return 0; // EOF + } + + _pos = 0; + } + + int n = Math.Min(count, _current.Length - _pos); + _current.CopyTo(_pos, buffer, index, n); + _pos += n; + return n; + } + } + + private readonly ChunkedReader _source = new ChunkedReader(); + private readonly ProcessOutputWrapper _wrapper; + + public ProcessOutputWrapperTest() + { + _wrapper = new ProcessOutputWrapper(_source); + _wrapper.StartReading(); + } + + public void Dispose() + { + _source.Complete(); + _wrapper.Dispose(); + } + + // Перекачка фоновая, ждем видимого эффекта от отданной порции + private T Eventually(Func read, Func arrived) + { + var sw = Stopwatch.StartNew(); + while (true) + { + var value = read(); + if (arrived(value) || sw.ElapsedMilliseconds > 5000) + return value; + + Thread.Sleep(1); + } + } + + private string EventuallyReadLine() => Eventually(() => _wrapper.ReadLine(), line => line != null); + + [Fact] + public void ReadLine_ReturnsLinesInOrder() + { + _source.Push("L1\nL2\nL3\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + EventuallyReadLine().Should().Be("L3"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Гонка из issue #1726: читатель вычерпал буфер по границе строки, + // затем поступила следующая порция. Лишняя пустая строка не появляется. + [Fact] + public void ReadLine_DoesNotInsertEmptyLine_WhenBufferDrainedBetweenWrites() + { + _source.Push("L1\nL2\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("L3\n"); + + EventuallyReadLine().Should().Be("L3"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Незавершенная строка не возвращается ни целиком, ни по кускам, + // пока не придет ее терминатор + [Fact] + public void ReadLine_DoesNotTearLine_SplitBetweenWrites() + { + _source.Push("L1\nПол"); + + EventuallyReadLine().Should().Be("L1"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("овина\n"); + + EventuallyReadLine().Should().Be("Половина"); + } + + // Длинная строка, приходящая многими порциями: промежуточные опросы + // возвращают null, итоговая строка собирается целиком + [Fact] + public void ReadLine_AssemblesLine_FromManyChunks() + { + _source.Push("aa"); + Eventually(() => _wrapper.Peek(), c => c != -1); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("bb"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("cc\n"); + EventuallyReadLine().Should().Be("aabbcc"); + _wrapper.ReadLine().Should().BeNull(); + } + + // CRLF, разорванный между порциями, не дает ни фантомной пустой строки, + // ни '\r' в теле строки + [Fact] + public void ReadLine_HandlesCrLfSplitBetweenWrites() + { + _source.Push("L1\r"); + + _wrapper.ReadLine().Should().BeNull(); + + _source.Push("\nL2\r\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + [Fact] + public void ReadLine_PreservesRealEmptyLines() + { + _source.Push("L1\n\nL2\n"); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be(""); + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Пустая последняя строка вывода больше не теряется + [Fact] + public void ReadLine_PreservesTrailingEmptyLine() + { + _source.Push("L1\n\n"); + _source.Complete(); + + EventuallyReadLine().Should().Be("L1"); + EventuallyReadLine().Should().Be(""); + _wrapper.ReadLine().Should().BeNull(); + } + + // Вывод, не завершенный переводом строки, возвращается по концу потока + [Fact] + public void ReadLine_ReturnsUnterminatedTail_AfterStreamEnd() + { + _source.Push("L1\nL2"); + + EventuallyReadLine().Should().Be("L1"); + _wrapper.ReadLine().Should().BeNull(); + + _source.Complete(); + + EventuallyReadLine().Should().Be("L2"); + _wrapper.ReadLine().Should().BeNull(); + } + + // Прочитать() возвращает подлинные символы вывода процесса, + // без синтеза и без подрезки + [Fact] + public void ReadToEnd_ReturnsRawOutput() + { + _source.Push("L1\nL2\n"); + + Eventually(() => _wrapper.Peek(), c => c != -1); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.ReadToEnd().Should().Be("L2\n"); + } + + // Ошибка чтения источника не глотается: она перебрасывается читателю, + // но только после того, как он вычитает накопленные данные + [Fact] + public void SourceFailure_SurfacesToReader_AfterBufferedDataIsConsumed() + { + _source.Push("L1\n"); + _source.Fail(new IOException("pipe failure")); + + EventuallyReadLine().Should().Be("L1"); + + var error = Eventually(ReadLineError, e => e != null); + error.Should().BeOfType().Which.Message.Should().Be("pipe failure"); + + // ошибка не одноразовая: последующие обращения тоже ее видят + ReadLineError().Should().BeOfType(); + } + + private Exception ReadLineError() + { + try + { + _wrapper.ReadLine(); + return null; + } + catch (Exception e) + { + return e; + } + } + + // После закрытия читателя источник продолжает дренироваться + // (иначе процесс заблокируется на записи в переполненный пайп), + // но данные больше не накапливаются + [Fact] + public void AfterDispose_SourceIsStillDrained_ButDataIsDiscarded() + { + _source.Push("L1\n"); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.Dispose(); + _source.Push("L2\n"); + + Eventually(() => _source.AllTaken, taken => taken).Should().BeTrue(); + _wrapper.ReadLine().Should().BeNull(); + } + + // Вычитанный префикс буфера освобождается: вывод долгоживущего + // процесса не накапливается в памяти при аккуратном читателе + [Fact] + public void ConsumedPrefix_IsCompacted() + { + var line = new string('x', 1000); + for (int i = 0; i < 20; i++) + { + _source.Push(line + "\n"); + EventuallyReadLine().Should().Be(line); + } + + _wrapper.InternalBufferSize.Should().BeLessThan(2 * 4096); + } + + [Fact] + public void IsDrained_BecomesTrue_OnlyAfterSourceEnd() + { + _source.Push("L1\n"); + EventuallyReadLine().Should().Be("L1"); + + _wrapper.IsDrained.Should().BeFalse(); + + _source.Complete(); + + Eventually(() => _wrapper.IsDrained, drained => drained).Should().BeTrue(); + } + } +} diff --git a/tests/process.os b/tests/process.os index f8db0b823..722ef93e5 100644 --- a/tests/process.os +++ b/tests/process.os @@ -11,6 +11,7 @@ Если Найти(СИ.ВерсияОС, "Windows") > 0 Тогда ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводOscriptСразу"); ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводOscriptПострочно"); + ВсеТесты.Добавить("ТестДолжен_ПрочитатьВыводБезКонцевогоПереводаСтроки"); ВсеТесты.Добавить("ТестДолжен_ПроверитьЧтоМетодПрочитатьНаПустомПотокеВернетНеопределено"); ВсеТесты.Добавить("ТестДолжен_ЗаписатьВоВходнойПотокПроцесса"); ВсеТесты.Добавить("ТестДолжен_НайтиПроцессПоИмени"); @@ -33,11 +34,12 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + + // Прочитать() возвращает вывод как есть, включая завершающий перевод строки Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); - + КонецПроцедуры Процедура ТестДолжен_ПрочитатьВыводOscriptПострочно() Экспорт @@ -59,13 +61,29 @@ Стр = Стр + СтрокаПотока + " |"; КонецЦикла; - - Стр = СокрП(Стр); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), НормализоватьПереводыСтрок(Стр)); КонецПроцедуры +Процедура ТестДолжен_ПрочитатьВыводБезКонцевогоПереводаСтроки() Экспорт + + ИмяСкрипта = юТест.ИмяВременногоФайла("os"); + ЗаписьТекста = Новый ЗаписьТекста(ИмяСкрипта, КодировкаТекста.UTF8); + ЗаписьТекста.ЗаписатьСтроку("Консоль.ВывестиСтроку(""line1"");"); + ЗаписьТекста.Записать("Консоль.Вывести(""line2"");"); + ЗаписьТекста.Закрыть(); + + Процесс = СоздатьПроцесс(ПутьОСкрипт() + " """ + ИмяСкрипта + """",, Истина); + Процесс.Запустить(); + Процесс.ОжидатьЗавершения(); + + Стр = НормализоватьПереводыСтрок(Процесс.ПотокВывода.Прочитать()); + + юТест.ПроверитьРавенство("line1" + Символы.ПС + "line2", Стр); + +КонецПроцедуры + Процедура ТестДолжен_ЗаписатьВоВходнойПотокПроцесса() Экспорт КодировкаПоУмолчанию = Консоль.КодировкаВходногоПотока; @@ -88,7 +106,8 @@ Эталон = НормализоватьПереводыСтрок("1 |2 - |3"); + |3 + |"); юТест.ПроверитьРавенство(Эталон, НормализоватьПереводыСтрок(Вывод)); КонецПроцедуры @@ -168,8 +187,9 @@ | |CGI Mode: | oscript.exe -cgi [script_arguments...] - | Runs as CGI application under HTTP-server."; - + | Runs as CGI application under HTTP-server. + |"; + Возврат НормализоватьПереводыСтрок(Текст); КонецФункции @@ -235,9 +255,9 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); КонецПроцедуры @@ -250,9 +270,9 @@ Процесс.Запустить(); Поток = Процесс.ПотокВывода; Процесс.ОжидатьЗавершения(); - + Стр = НормализоватьПереводыСтрок(Поток.Прочитать()); - + юТест.ПроверитьРавенство(ШтатныйВыводOscript(), Стр); КонецПроцедуры diff --git a/tests/showversion.os b/tests/showversion.os index 4beaf301c..f71a71554 100644 --- a/tests/showversion.os +++ b/tests/showversion.os @@ -34,9 +34,10 @@ Процесс.Запустить(); Процесс.ОжидатьЗавершения(); - ДанныеВывода = Процесс.ПотокВывода.Прочитать(); + // Прочитать() возвращает вывод как есть, включая завершающий перевод строки + ДанныеВывода = СокрЛП(Процесс.ПотокВывода.Прочитать()); - юТест.ПроверитьРавенство(ДанныеВывода, СистемнаяИнформация.Версия, "Версия вывода не совпадает с системной информацией!"); + юТест.ПроверитьРавенство(СистемнаяИнформация.Версия, ДанныеВывода, "Версия вывода не совпадает с системной информацией!"); КонецЦикла;