8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
301 lines
12 KiB
Python
301 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Tests for `tools/snapshot_defer.py` -- the parts that need no Revit.
|
|
|
|
The tool's job splits cleanly: WAIT for a process, then LAUNCH one. The wait
|
|
is the half that can be got wrong invisibly (it can decide a live Revit is
|
|
gone, and then two 10 GB models are resident at once, which is the single
|
|
outcome the whole design exists to prevent), so its clock, its sleep and its
|
|
liveness check are all injected and all tested here. The launch is one argv
|
|
built by `vibe.snapshotmeta`, tested there and asserted end-to-end here.
|
|
|
|
Loaded by path, like `test_knowledge_drift.py`: this is host CPython 3.12 code
|
|
sitting in a repository whose extension is IronPython 2.7.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__))))
|
|
TOOLS = os.path.join(ROOT, 'tools')
|
|
if TOOLS not in sys.path:
|
|
sys.path.insert(0, TOOLS)
|
|
|
|
from vibe import snapshotmeta as S # noqa: E402 (registers the vibe stub)
|
|
|
|
snapshot_defer = pytest.importorskip('snapshot_defer')
|
|
D = snapshot_defer
|
|
|
|
# Built, never spelled out -- see the note in test_snapshotmeta.py.
|
|
MODEL = 'm_detached' + S.MODEL_EXT
|
|
|
|
|
|
class Clock(object):
|
|
"""A wall clock that only moves when something sleeps on it."""
|
|
|
|
def __init__(self):
|
|
self.t = 1000.0
|
|
self.slept = []
|
|
|
|
def now(self):
|
|
return self.t
|
|
|
|
def sleep(self, seconds):
|
|
self.slept.append(seconds)
|
|
self.t += seconds
|
|
|
|
|
|
# ---------------------------------------------------------------- the wait
|
|
|
|
def test_returns_at_once_when_the_process_is_already_gone():
|
|
clock = Clock()
|
|
assert D.wait_for_exit(123, 600, alive=lambda pid: False,
|
|
sleep=clock.sleep, clock=clock.now) is True
|
|
assert clock.slept == []
|
|
|
|
|
|
def test_waits_until_the_process_goes_and_then_returns_true():
|
|
clock = Clock()
|
|
calls = {'n': 0}
|
|
|
|
def alive(_pid):
|
|
calls['n'] += 1
|
|
return calls['n'] <= 3
|
|
|
|
assert D.wait_for_exit(123, 600, alive=alive, sleep=clock.sleep,
|
|
clock=clock.now, poll=5) is True
|
|
assert clock.slept == [5, 5, 5]
|
|
|
|
|
|
def test_gives_up_and_says_so_rather_than_launching_anyway():
|
|
# The Revit that never closes -- an unanswered save prompt. Returning
|
|
# False is what makes that a reportable outcome instead of a second Revit
|
|
# started next to a live one.
|
|
clock = Clock()
|
|
assert D.wait_for_exit(123, 60, alive=lambda pid: True, sleep=clock.sleep,
|
|
clock=clock.now, poll=10) is False
|
|
assert clock.t - 1000.0 >= 60
|
|
|
|
|
|
def test_a_zero_pid_is_not_waited_on():
|
|
assert D.wait_for_exit(0, 600, alive=lambda pid: True,
|
|
sleep=lambda s: None, clock=lambda: 0.0) is True
|
|
|
|
|
|
def test_process_alive_says_false_for_a_pid_that_cannot_exist():
|
|
# 0xFFFFFFF0 is not a valid Windows pid. The point of the assertion is
|
|
# that the real implementation answers rather than raising.
|
|
assert D.process_alive(0xFFFFFFF0) in (True, False)
|
|
|
|
|
|
def test_process_alive_is_false_for_no_pid():
|
|
assert D.process_alive(0) is False
|
|
assert D.process_alive(None) is False
|
|
|
|
|
|
def test_this_very_process_is_alive():
|
|
assert D.process_alive(os.getpid()) is True
|
|
|
|
|
|
# ------------------------------------------- the machine, not just one pid
|
|
|
|
def test_a_revit_started_again_during_the_wait_is_seen():
|
|
# The hole this closes: he closes Revit, the tool starts waiting, he
|
|
# opens Revit again for something else. Waiting only on the original pid
|
|
# would launch a second 10 GB model beside his live one.
|
|
assert D.any_revit_running(run=lambda argv: 'Revit.exe 1234 Console') \
|
|
is True
|
|
|
|
|
|
def test_an_empty_task_list_means_the_machine_is_free():
|
|
assert D.any_revit_running(
|
|
run=lambda argv: 'INFO: No tasks are running.') is False
|
|
|
|
|
|
def test_a_task_list_that_will_not_run_counts_as_busy():
|
|
# Unknown must never read as free: being wrong that way thrashes the
|
|
# workstation, being wrong the other way costs a wait.
|
|
def explode(argv):
|
|
raise OSError('tasklist is not on the path')
|
|
|
|
assert D.any_revit_running(run=explode) is True
|
|
|
|
|
|
def test_the_machine_wide_wait_uses_the_same_loop_and_can_time_out():
|
|
clock = Clock()
|
|
assert D.wait_for_exit(1, 60, alive=lambda _pid: True, sleep=clock.sleep,
|
|
clock=clock.now, poll=10) is False
|
|
|
|
|
|
# ------------------------------------------------------------- the command
|
|
|
|
def test_command_for_matches_what_the_shared_module_builds():
|
|
assert D.command_for('p.exe', 'r.py', 2025) == \
|
|
S.pyrevit_run_argv('p.exe', 'r.py', 2025)
|
|
|
|
|
|
def test_command_for_without_a_year():
|
|
assert D.command_for('p.exe', 'r.py') == ['p.exe', 'run', 'r.py', '--purge']
|
|
|
|
|
|
# ------------------------------------------------------------- the dry run
|
|
|
|
def write_request(tmp_path, **over):
|
|
plan = S.plan(str(tmp_path / 'snap'), 'model_detached', '20260830-1612')
|
|
request = {'run': 'r20260830-161200', 'base': 'model_detached',
|
|
'layout': 'folder', 'worksets': 'preserve', 'plan': plan,
|
|
'log': str(tmp_path / 'snapshots.jsonl'),
|
|
'source': {'path': str(tmp_path / ('model' + S.MODEL_EXT)), 'cloud': None}}
|
|
request.update(over)
|
|
path = tmp_path / 'req.json'
|
|
path.write_text(json.dumps(request), encoding='utf-8')
|
|
return str(path), request
|
|
|
|
|
|
def test_dry_run_prints_the_command_and_launches_nothing(tmp_path, capsys):
|
|
req_path, request = write_request(tmp_path)
|
|
code = D.main(['--request', req_path, '--runner', r'C:\x\run.py',
|
|
'--pyrevit', r'C:\x\pyrevit.exe', '--revit', '2025',
|
|
'--wait-pid', '4242', '--dry-run'])
|
|
out = capsys.readouterr().out
|
|
assert code == 0
|
|
assert 'pyrevit.exe" run "C:\\x\\run.py" --revit=2025 --purge' in out \
|
|
or 'pyrevit.exe run C:\\x\\run.py --revit=2025 --purge' in out
|
|
assert request['plan']['path'] in out
|
|
assert 'WILLTOOLS_SNAPSHOT_REQUEST' in out
|
|
assert '4242' in out
|
|
# nothing was created, nothing was journalled
|
|
assert not os.path.exists(request['log'])
|
|
|
|
|
|
def test_dry_run_names_the_target_the_finder_will_later_find(tmp_path, capsys):
|
|
req_path, request = write_request(tmp_path)
|
|
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
|
'--dry-run'])
|
|
out = capsys.readouterr().out
|
|
target = request['plan']['path']
|
|
assert target in out
|
|
# and once that file exists, discovery agrees it is the newest snapshot
|
|
os.makedirs(os.path.dirname(target))
|
|
with open(target, 'w') as fh:
|
|
fh.write('x')
|
|
assert S.newest(request['plan']['folder'].rsplit(os.sep, 1)[0])['path'] \
|
|
== target
|
|
|
|
|
|
# ------------------------------------------------------- listing what exists
|
|
|
|
def test_newest_listing_reports_nothing_when_there_is_nothing(tmp_path, capsys):
|
|
code = D.main(['--newest', str(tmp_path)])
|
|
assert code == 1
|
|
assert 'no snapshots' in capsys.readouterr().out
|
|
|
|
|
|
def test_newest_listing_marks_the_newest(tmp_path, capsys):
|
|
root = str(tmp_path)
|
|
for stamp in ('20260828-0730', '20260830-1612'):
|
|
folder = os.path.join(root, 'm_detached_' + stamp)
|
|
os.makedirs(folder)
|
|
with open(os.path.join(folder, MODEL), 'w') as fh:
|
|
fh.write('x')
|
|
code = D.main(['--newest', root])
|
|
out = capsys.readouterr().out
|
|
assert code == 0
|
|
assert out.strip().endswith('m_detached_20260830-1612' + os.sep +
|
|
MODEL)
|
|
assert '* 20260830-1612' in out
|
|
assert ' 20260828-0730' in out
|
|
|
|
|
|
# --------------------------------------------------- the 2026-09-02 incident
|
|
#
|
|
# A press on 2026-09-01 was journalled as failed within seconds by the CYCLE
|
|
# route (not the after-close route the report first assumed), and the
|
|
# runner's own FAILED line was never written at all -- meaning it crashed
|
|
# before it could read its own request. Separately, this machine has five
|
|
# Revit versions attached and `pyrevit run` with no `--revit=` reproducibly
|
|
# hits the same bare "Object reference not set to an instance of an object"
|
|
# pyRevit CLI wraps every unhandled exception in. These tests lock in the
|
|
# three changes that came out of that investigation: `--pyrevit`
|
|
# auto-discovery (so a run started without it is no longer a silent
|
|
# `argparse.error()`), a loud journal line even when the request itself
|
|
# cannot be read, and a `--revit=<year>` reminder in both human-facing
|
|
# outputs (dry-run print, and the FAILED line of a real run).
|
|
|
|
def test_pyrevit_is_auto_discovered_when_left_off(tmp_path, monkeypatch,
|
|
capsys):
|
|
req_path, _request = write_request(tmp_path)
|
|
monkeypatch.setattr(S, 'pyrevit_exe_guess',
|
|
lambda: r'C:\guessed\pyrevit.exe')
|
|
code = D.main(['--request', req_path, '--runner', 'r.py', '--dry-run'])
|
|
out = capsys.readouterr().out
|
|
assert code == 0
|
|
assert r'C:\guessed\pyrevit.exe' in out
|
|
|
|
|
|
def test_a_run_with_no_pyrevit_anywhere_journals_instead_of_arg_erroring(
|
|
tmp_path, monkeypatch, capsys):
|
|
"""Until 2026-09-02 this was `ap.error()`: stderr only, exit 2, and the
|
|
ledger stayed exactly as empty as a run that never started at all."""
|
|
req_path, request = write_request(tmp_path)
|
|
monkeypatch.setattr(S, 'pyrevit_exe_guess', lambda: None)
|
|
code = D.main(['--request', req_path, '--runner', 'r.py'])
|
|
assert code == 2
|
|
rows = S.read(request['log'])
|
|
assert rows and rows[-1]['event'] == S.FAILED
|
|
assert 'pyRevit CLI was not found' in rows[-1]['error']
|
|
|
|
|
|
def test_an_unreadable_request_still_journals_to_the_detached_dir(tmp_path):
|
|
"""The request file IS where `log` lives, so a request this tool cannot
|
|
even parse used to leave nothing anywhere -- not even the traceback had
|
|
a home. `fallback_log_for` recovers `<detached_dir>/snapshots.jsonl`
|
|
from the `_requests/<run>.json` shape of the path alone."""
|
|
detached_dir = tmp_path / 'snaps'
|
|
req_dir = detached_dir / '_requests'
|
|
req_dir.mkdir(parents=True)
|
|
bad_req = req_dir / 'c-broken.json'
|
|
bad_req.write_text('not json at all {{{', encoding='utf-8')
|
|
code = D.main(['--request', str(bad_req), '--runner', 'r.py',
|
|
'--pyrevit', 'p.exe'])
|
|
assert code == 2
|
|
rows = S.read(str(detached_dir / S.LOG_NAME))
|
|
assert rows and rows[-1]['event'] == S.WATCHER_CRASHED
|
|
assert rows[-1]['run'] is None
|
|
|
|
|
|
def test_dry_run_warns_when_no_revit_year_was_given(tmp_path, capsys):
|
|
req_path, _request = write_request(tmp_path)
|
|
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
|
'--dry-run'])
|
|
out = capsys.readouterr().out
|
|
assert 'WARNING' in out and '--revit=<year>' in out
|
|
|
|
|
|
def test_dry_run_does_not_warn_when_a_revit_year_was_given(tmp_path, capsys):
|
|
req_path, _request = write_request(tmp_path)
|
|
D.main(['--request', req_path, '--runner', 'r.py', '--pyrevit', 'p.exe',
|
|
'--revit', '2024', '--dry-run'])
|
|
out = capsys.readouterr().out
|
|
assert 'WARNING' not in out
|
|
|
|
|
|
def test_a_real_run_journals_watcher_launched_before_touching_the_cli(
|
|
tmp_path):
|
|
"""The proof this process actually started and got as far as the wait/
|
|
launch step -- a REQUESTED with no WATCHER_LAUNCHED after it now means
|
|
this tool never got this far at all, not "still running"."""
|
|
req_path, request = write_request(tmp_path)
|
|
code = D.main(['--request', req_path, '--runner', 'r.py',
|
|
'--pyrevit', sys.executable])
|
|
assert code == 2 # sys.executable is not pyrevit.exe
|
|
rows = S.read(request['log'])
|
|
events = [row['event'] for row in rows]
|
|
assert S.WATCHER_LAUNCHED in events
|
|
assert events.index(S.WATCHER_LAUNCHED) < len(events) - 1
|
|
launched = [r for r in rows if r['event'] == S.WATCHER_LAUNCHED][0]
|
|
assert launched['pyrevit_exe'] == sys.executable
|
|
assert launched['revit_year'] is None
|