加快门禁:删除测试窗口、释放桩线程并并行运行三个阶段
关闭后未删除的测试窗口让每次主题切换重排全部残留控件,ui_check 每次关窗还要等桩线程 5 秒。Linux 上结论不变,test_ui_smoke 75→64 s、ui_check 52→16 s;Windows 门禁结果见 .scratch/gate-speed/。
This commit is contained in:
@@ -118,8 +118,9 @@ is meant to be unskippable:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools\gate.ps1
|
||||
```
|
||||
|
||||
It runs `tools/lint_gate.py` over the changed files, the full studio suite, the full
|
||||
shared suite, and `ui_check.py`; writes each one's output to
|
||||
It runs `tools/lint_gate.py` over the changed files, then the full studio suite, the full
|
||||
shared suite, and `ui_check.py` at the same time (`-Serial` runs those three one after
|
||||
another with live output, for reading a failing stage); writes each one's output to
|
||||
`.scratch/gate/gate-<run-id>/`; and holds the run to three contracts — an **empty**
|
||||
`tools/known_failures.txt`, the skip ceilings and collection floors in
|
||||
`tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of
|
||||
|
||||
@@ -97,6 +97,8 @@ def make_window(qt_app, tmp_path, monkeypatch):
|
||||
|
||||
yield _make
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QEvent
|
||||
|
||||
for window in created:
|
||||
try:
|
||||
window.close()
|
||||
@@ -104,6 +106,14 @@ def make_window(qt_app, tmp_path, monkeypatch):
|
||||
# Reported rather than swallowed: a window that cannot close is a Qt lifetime
|
||||
# problem worth seeing, but it must not turn a green test red.
|
||||
logging.getLogger(__name__).debug("could not close a test window", exc_info=True)
|
||||
# ``close()`` only hides a window: something in the window keeps it referenced,
|
||||
# so it stayed alive for the rest of the session. Every leaked window's ~600
|
||||
# widgets are then re-polished by each later ``app.setStyleSheet`` — measured on
|
||||
# Linux, one theme switch takes 0.07 s with one window and 0.85 s after ten closed
|
||||
# ones, and three theme tests took 76 s of a 231 s Windows suite. Delete it.
|
||||
window.deleteLater()
|
||||
created.clear()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1048,7 +1048,9 @@ def test_the_domain_boundary_is_latched_for_the_session(make_window):
|
||||
"the previous session's island survived into a new run"
|
||||
)
|
||||
window._run_timer.stop()
|
||||
window._stop_run(reason="user_stop")
|
||||
# Release the stand-in worker as well: the second run re-armed its hang, and a
|
||||
# window closed on a hanging worker waits out its 5 s join in teardown.
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- experiment record
|
||||
|
||||
@@ -414,6 +414,22 @@ DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _close_window(window) -> None:
|
||||
"""Close a check window, releasing its stand-in worker first.
|
||||
|
||||
A ``_StuckWorker`` hangs on purpose, and the window's shutdown path stops its
|
||||
worker with ``wait=True``, which joins that thread for up to 5 s. Every scene that
|
||||
closed a window on a still-hanging stub paid those 5 s (measured: 7 joins, 35 s of
|
||||
a 52 s run). What those scenes check happens before the close, so the stub is let
|
||||
go here; the shutdown itself is checked separately in a subprocess.
|
||||
"""
|
||||
for name in ("_box_worker", "_finger_worker"):
|
||||
worker = getattr(window, name, None)
|
||||
if isinstance(worker, _StuckWorker):
|
||||
worker.release()
|
||||
window.close()
|
||||
|
||||
|
||||
def _srgb_channel(value: int) -> float:
|
||||
channel = value / 255.0
|
||||
if channel <= 0.03928:
|
||||
@@ -836,7 +852,7 @@ def main() -> int:
|
||||
len(window.prefs.get("splitter_sizes", [])) == 4,
|
||||
"splitter sizes not persisted",
|
||||
)
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
section("small window")
|
||||
@@ -885,7 +901,7 @@ def main() -> int:
|
||||
f" in {type(parent).__name__} {parent.width()}x{parent.height()}"
|
||||
)
|
||||
check(not oversize, f"widgets overflow their parent: {oversize}")
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
section("top area")
|
||||
@@ -1072,7 +1088,7 @@ def main() -> int:
|
||||
not window.toolbar.settings_expanded(),
|
||||
f"{theme}: the settings area could not be collapsed again",
|
||||
)
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
# English labels are the widest case ("Start recording", "Sensor config"),
|
||||
@@ -1096,7 +1112,7 @@ def main() -> int:
|
||||
f"the sensor summary is not stated in full: "
|
||||
f"{english_window.toolbar.btn_sensor.text()!r}",
|
||||
)
|
||||
english_window.close()
|
||||
_close_window(english_window)
|
||||
app.processEvents()
|
||||
set_locale("zh_CN")
|
||||
|
||||
@@ -1153,7 +1169,7 @@ def main() -> int:
|
||||
f" reset label at 1280x720, {language}: live {live_width}px, "
|
||||
f"replay {replay_width}px (button width {button.width()}px)"
|
||||
)
|
||||
measured.close()
|
||||
_close_window(measured)
|
||||
app.processEvents()
|
||||
set_locale("zh_CN")
|
||||
|
||||
@@ -1320,7 +1336,7 @@ def main() -> int:
|
||||
window.toolbar.set_record_checked(False)
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
section("stopping a hung connect")
|
||||
@@ -1388,7 +1404,7 @@ def main() -> int:
|
||||
"Esc did not cancel the hung connect immediately",
|
||||
)
|
||||
check(not window._running, "Esc left the session running")
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
# Let the deliberately-stuck thread finish before the interpreter tears
|
||||
@@ -1491,7 +1507,7 @@ def main() -> int:
|
||||
"the depth colour scale re-fitted per frame instead of staying fixed",
|
||||
)
|
||||
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
@@ -1797,14 +1813,14 @@ def main() -> int:
|
||||
"the 2D image area does not follow the output image's aspect ratio: "
|
||||
f"{image.width()}x{image.height()}",
|
||||
)
|
||||
finger_window.close()
|
||||
_close_window(finger_window)
|
||||
app.processEvents()
|
||||
check(
|
||||
window.toolbar.record_chip.text() == t("record.state_idle"),
|
||||
f"the record strip did not fall back to idle: "
|
||||
f"{window.toolbar.record_chip.text()!r}",
|
||||
)
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
|
||||
section("chart header and unit chip")
|
||||
@@ -1892,7 +1908,7 @@ def main() -> int:
|
||||
# The locale is process-wide: leave it where the other sections expect it.
|
||||
header_window._set_language("zh_CN")
|
||||
app.processEvents()
|
||||
header_window.close()
|
||||
_close_window(header_window)
|
||||
app.processEvents()
|
||||
|
||||
section("acquisition correctness")
|
||||
@@ -2156,7 +2172,7 @@ def main() -> int:
|
||||
pane_window.replay.close()
|
||||
pane_window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
pane_window.close()
|
||||
_close_window(pane_window)
|
||||
app.processEvents()
|
||||
_shutil.rmtree(replay_root, ignore_errors=True)
|
||||
|
||||
@@ -2323,7 +2339,7 @@ def main() -> int:
|
||||
)
|
||||
check(meta["stats"]["frames"] == 31, f"unexpected frame count: {meta['stats']['frames']}")
|
||||
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
_shutil.rmtree(scratch, ignore_errors=True)
|
||||
@@ -2430,7 +2446,7 @@ def main() -> int:
|
||||
f"the session phase is {fail_window._session_phase!r} after a failed recording",
|
||||
)
|
||||
|
||||
fail_window.close()
|
||||
_close_window(fail_window)
|
||||
app.processEvents()
|
||||
stuck_fail.release()
|
||||
_app_module.recordings_dir = lambda: scratch
|
||||
@@ -2605,7 +2621,7 @@ def main() -> int:
|
||||
f"a matching calibration was not requested: {stuck_identity.calibration_requests}",
|
||||
)
|
||||
|
||||
identity_window.close()
|
||||
_close_window(identity_window)
|
||||
app.processEvents()
|
||||
_shutil.rmtree(record_dir, ignore_errors=True)
|
||||
|
||||
@@ -2657,7 +2673,7 @@ def main() -> int:
|
||||
units_plots.history.sample_count > 0,
|
||||
"the rebuilt history lost every sample",
|
||||
)
|
||||
units_window.close()
|
||||
_close_window(units_window)
|
||||
units_window._box_worker = None
|
||||
stuck_units.release()
|
||||
app.processEvents()
|
||||
@@ -2716,7 +2732,7 @@ def main() -> int:
|
||||
and not any(_math.isfinite(v) for v in one_state.values[:, 1]),
|
||||
"FX carried a value without a calibration curve",
|
||||
)
|
||||
one_window.close()
|
||||
_close_window(one_window)
|
||||
one_window._box_worker = None
|
||||
stuck_one.release()
|
||||
app.processEvents()
|
||||
@@ -2903,7 +2919,7 @@ def main() -> int:
|
||||
check(plots._source_ready, "switching units detached the curve source")
|
||||
check(plots._unit == "N", f"the unit switch did not reach the charts: {plots._unit}")
|
||||
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
@@ -2996,7 +3012,7 @@ def main() -> int:
|
||||
"the way back to the camera did not take effect",
|
||||
)
|
||||
|
||||
window.close()
|
||||
_close_window(window)
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
@@ -3141,7 +3157,7 @@ def main() -> int:
|
||||
pane.close_view()
|
||||
check(True, "closing the 3D pane twice is safe")
|
||||
finally:
|
||||
pane_window.close()
|
||||
_close_window(pane_window)
|
||||
app.processEvents()
|
||||
|
||||
section("replay")
|
||||
@@ -3325,7 +3341,7 @@ def main() -> int:
|
||||
)
|
||||
other_window.replay_ctl.stop_activity()
|
||||
app.processEvents()
|
||||
other_window.close()
|
||||
_close_window(other_window)
|
||||
app.processEvents()
|
||||
replay_window.activateWindow()
|
||||
app.processEvents()
|
||||
@@ -3706,7 +3722,7 @@ def main() -> int:
|
||||
)
|
||||
|
||||
replay_window.replay.close()
|
||||
replay_window.close()
|
||||
_close_window(replay_window)
|
||||
app.processEvents()
|
||||
|
||||
_shutil.rmtree(replay_root, ignore_errors=True)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
代理开始验证前说明“改动 → 所选测试 → 扩大理由”。定向测试通过后结束验证;没有新增改动、失败或未解决风险时,不追加全量。全量只在最终候选状态运行一次;之后仅复验新改动涉及的测试,除非新改动再次触及公共基础。
|
||||
|
||||
**提交前跑一次 `tools/gate.ps1`。** 定向测试用于开发中,门禁用于“这一版可以交给别人”的那一刻:它依次跑改动文件的 lint、studio 全量、shared 全量、`ui_check.py`,并要求 `tools/known_failures.txt` 为空、跳过数与收集数在 `tools/skip_budget.txt` 之内。可选 `pre-push` 钩子会调用它(安装方式见 [README](../README.md),不自动安装)。门禁平台是 **Windows**:测试渲染真实 Qt 控件,而 [测试修复与门禁计划](test-repair-plan.zh.md) 第 3 节的 11、13 号只在 Linux 上失败,Linux 的通过不等于同一结论。
|
||||
**提交前跑一次 `tools/gate.ps1`。** 定向测试用于开发中,门禁用于“这一版可以交给别人”的那一刻:它先跑改动文件的 lint,再同时跑 studio 全量、shared 全量与 `ui_check.py`(`-Serial` 改为逐个运行并实时显示输出),并要求 `tools/known_failures.txt` 为空、跳过数与收集数在 `tools/skip_budget.txt` 之内。可选 `pre-push` 钩子会调用它(安装方式见 [README](../README.md),不自动安装)。门禁平台是 **Windows**:测试渲染真实 Qt 控件,而 [测试修复与门禁计划](test-repair-plan.zh.md) 第 3 节的 11、13 号只在 Linux 上失败,Linux 的通过不等于同一结论。
|
||||
|
||||
不能通过跳过失败、删除断言或把必需测试标成可选来提速。测试缺依赖、收集为空或意外跳过必须报告。间歇性错误先保留证据,查明影响后只重跑受影响范围;不循环重跑全量碰运气。
|
||||
|
||||
|
||||
+105
-18
@@ -3,13 +3,17 @@
|
||||
Merge gate for orisys-app: lint the change, run both test suites, run the UI check.
|
||||
|
||||
.DESCRIPTION
|
||||
One command that a change cannot skip. It runs, in order:
|
||||
One command that a change cannot skip. It runs:
|
||||
|
||||
1. tools/lint_gate.py the narrow lint rule set over the changed files
|
||||
2. pytest apps/studio/tests full, with the ``timing`` tests deselected
|
||||
3. pytest shared/tests full, with the ``timing`` tests deselected
|
||||
4. apps/studio/tools/ui_check.py the layout / theme / record-channel checks
|
||||
|
||||
Lint runs first; 2-4 then run at the same time (they write to separate places), so
|
||||
the gate takes as long as the slowest of them. -Serial runs 2-4 one after another
|
||||
with live output; the verdict is the same either way.
|
||||
|
||||
and then holds the run to three contracts:
|
||||
|
||||
* tools/known_failures.txt must be empty. Any content fails the gate: a known
|
||||
@@ -44,6 +48,11 @@
|
||||
.PARAMETER SkipLint
|
||||
Run only the suites and the UI check (used when diagnosing a red gate).
|
||||
|
||||
.PARAMETER Serial
|
||||
Run the studio suite, the shared suite and ui_check one after another with live
|
||||
output, instead of at the same time. The verdict is the same; use it to read a
|
||||
failing stage as it runs, or when a parallel run is suspected of interfering.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1
|
||||
|
||||
@@ -58,7 +67,8 @@ param(
|
||||
[string]$Task = 'gate',
|
||||
[string]$Python = '',
|
||||
[string]$LintBase = 'origin/main',
|
||||
[switch]$SkipLint
|
||||
[switch]$SkipLint,
|
||||
[switch]$Serial
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -218,22 +228,21 @@ try {
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- test suites
|
||||
function Invoke-Suite([string]$Name, [string]$Path) {
|
||||
function Get-SuiteArguments([string]$Name, [string]$Path) {
|
||||
$safe = $Name.Replace(' ', '-')
|
||||
$log = Join-Path $Scratch "$safe.log"
|
||||
$xml = Join-Path $Scratch "$safe.xml"
|
||||
$base = Join-Path $Scratch "pytest-$safe"
|
||||
Write-Step "$Name"
|
||||
$exit = Invoke-Native $Python @(
|
||||
return @(
|
||||
'-m', 'pytest', $Path,
|
||||
'-m', 'not timing',
|
||||
'-p', 'no:cacheprovider',
|
||||
"--basetemp=$base",
|
||||
"--junitxml=$xml"
|
||||
) $log
|
||||
"--basetemp=$(Join-Path $Scratch "pytest-$safe")",
|
||||
"--junitxml=$(Join-Path $Scratch "$safe.xml")"
|
||||
)
|
||||
}
|
||||
|
||||
function Read-Suite([string]$Name, [int]$Exit) {
|
||||
$xml = Join-Path $Scratch "$($Name.Replace(' ', '-')).xml"
|
||||
$counts = [pscustomobject]@{
|
||||
Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml)
|
||||
Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $Exit; Xml = (Test-Path -LiteralPath $xml)
|
||||
}
|
||||
if ($counts.Xml) {
|
||||
try {
|
||||
@@ -252,6 +261,51 @@ try {
|
||||
return $counts
|
||||
}
|
||||
|
||||
# A stage started in the background: stdout and stderr go to two files, joined into
|
||||
# <stage>.log when it ends, so every stage leaves the same one log as a serial run.
|
||||
function Start-Stage([string]$Name, [string[]]$Arguments) {
|
||||
$safe = $Name.Replace(' ', '-')
|
||||
$out = Join-Path $Scratch "$safe.out.tmp"
|
||||
$err = Join-Path $Scratch "$safe.err.tmp"
|
||||
# Start-Process joins -ArgumentList with spaces and quotes nothing, so each
|
||||
# argument is quoted here ('not timing' is one argument).
|
||||
$quoted = @($Arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' })
|
||||
$options = @{
|
||||
FilePath = $Python
|
||||
ArgumentList = $quoted
|
||||
WorkingDirectory = $Repo
|
||||
RedirectStandardOutput = $out
|
||||
RedirectStandardError = $err
|
||||
NoNewWindow = $true
|
||||
PassThru = $true
|
||||
}
|
||||
$began = Get-Date
|
||||
$process = Start-Process @options
|
||||
# Windows PowerShell 5.1 reports no ExitCode for a -PassThru process unless its
|
||||
# handle was read while the process ran.
|
||||
$null = $process.Handle
|
||||
return [pscustomobject]@{
|
||||
Name = $Name; Process = $process; Out = $out; Err = $err; Began = $began
|
||||
Log = (Join-Path $Scratch "$safe.log")
|
||||
}
|
||||
}
|
||||
|
||||
function Complete-Stage($Stage) {
|
||||
$Stage.Process.WaitForExit()
|
||||
$exit = $Stage.Process.ExitCode
|
||||
# No exit code is not a pass: treat it like a crash that left no answer.
|
||||
if ($null -eq $exit) { $exit = -1 }
|
||||
$text = New-Object System.Collections.ArrayList
|
||||
foreach ($part in @($Stage.Out, $Stage.Err)) {
|
||||
if (Test-Path -LiteralPath $part) {
|
||||
foreach ($line in (Get-Content -LiteralPath $part -Encoding UTF8)) { [void]$text.Add($line) }
|
||||
Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Set-Content -LiteralPath $Stage.Log -Value $text -Encoding UTF8
|
||||
return $exit
|
||||
}
|
||||
|
||||
#: An access violation while Qt tears the window down after the last assertion has
|
||||
#: been seen (exit 0xC0000005 as a signed int). It is allowed and reported; every
|
||||
#: other non-zero exit from a suite with a clean JUnit report is a gate failure.
|
||||
@@ -311,18 +365,48 @@ try {
|
||||
exit 2
|
||||
}
|
||||
|
||||
Write-Step 'studio suite'
|
||||
$studio = Invoke-Suite 'studio suite' 'apps/studio/tests'
|
||||
# -------------------------------------------------------------- suites and ui_check
|
||||
# The three stages are independent processes and write to separate places (studio
|
||||
# tests: apps/studio/outputs/_test_record*, shared tests: shared/tests/_scratch,
|
||||
# ui_check: apps/studio/outputs/_*_check, each pytest run its own --basetemp), so by
|
||||
# default they run at the same time and the gate takes as long as the slowest one.
|
||||
# Child output is UTF-8: a finding with Chinese text must not crash a cp1252 console.
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
$stages = @(
|
||||
[pscustomobject]@{ Name = 'studio suite'; Args = (Get-SuiteArguments 'studio suite' 'apps/studio/tests') },
|
||||
[pscustomobject]@{ Name = 'shared suite'; Args = (Get-SuiteArguments 'shared suite' 'shared/tests') },
|
||||
[pscustomobject]@{ Name = 'ui_check'; Args = @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) }
|
||||
)
|
||||
$exits = @{}
|
||||
$durations = @{}
|
||||
if ($Serial) {
|
||||
foreach ($stage in $stages) {
|
||||
Write-Step $stage.Name
|
||||
$log = Join-Path $Scratch "$($stage.Name.Replace(' ', '-')).log"
|
||||
$began = Get-Date
|
||||
$exits[$stage.Name] = Invoke-Native $Python $stage.Args $log
|
||||
$durations[$stage.Name] = ((Get-Date) - $began).TotalSeconds
|
||||
}
|
||||
} else {
|
||||
Write-Step 'studio suite, shared suite and ui_check, in parallel (-Serial for live output)'
|
||||
$running = @(foreach ($stage in $stages) { Start-Stage $stage.Name $stage.Args })
|
||||
foreach ($stage in $running) {
|
||||
$exits[$stage.Name] = Complete-Stage $stage
|
||||
# StartTime cannot be read once a process has exited; the launch time is ours.
|
||||
$durations[$stage.Name] = ($stage.Process.ExitTime - $stage.Began).TotalSeconds
|
||||
Write-Host (" {0,-13} exit {1} {2:n0} s ({3})" -f $stage.Name, $exits[$stage.Name], $durations[$stage.Name], $stage.Log)
|
||||
}
|
||||
}
|
||||
|
||||
$studio = Read-Suite 'studio suite' $exits['studio suite']
|
||||
Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips'
|
||||
|
||||
Write-Step 'shared suite'
|
||||
$shared = Invoke-Suite 'shared suite' 'shared/tests'
|
||||
$shared = Read-Suite 'shared suite' $exits['shared suite']
|
||||
Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips'
|
||||
|
||||
# -------------------------------------------------------------- ui_check
|
||||
Write-Step 'ui_check'
|
||||
$uiLog = Join-Path $Scratch 'ui_check.log'
|
||||
$uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog
|
||||
$uiExit = $exits['ui_check']
|
||||
# ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the
|
||||
# verdict. Its exit code is also the verdict: a script that dies partway through
|
||||
# (missing worker signature, an import error) must not pass on a stale summary line.
|
||||
@@ -358,6 +442,9 @@ try {
|
||||
[void]$lines.Add("Platform : Windows")
|
||||
[void]$lines.Add("Python : $Python")
|
||||
[void]$lines.Add("LintBase : $LintBase")
|
||||
$mode = if ($Serial) { 'serial' } else { 'parallel' }
|
||||
$timing = ($stages | ForEach-Object { "{0} {1:n0} s" -f $_.Name, $durations[$_.Name] }) -join ', '
|
||||
[void]$lines.Add("Stages : $mode; $timing")
|
||||
[void]$lines.Add("Scratch : $Scratch")
|
||||
[void]$lines.Add('')
|
||||
[void]$lines.Add('| Check | Result | Detail |')
|
||||
|
||||
Reference in New Issue
Block a user