packet-tracer-skill 0.2.3 → 0.3.0
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.
- package/CHANGELOG.md +424 -73
- package/README.md +557 -442
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/l2-security-qos-proof.md +1 -1
- package/docs/packet-tracer-feature-gap-atlas.md +4 -4
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +17 -8
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +1 -1
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -53
- package/pytest.ini +9 -0
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1812
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/generate_pkt.py +8762 -4228
- package/scripts/intent_parser.py +2242 -1657
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1703
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1250
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verification for generated `.pkt` files, in two tiers.
|
|
3
|
+
|
|
4
|
+
Structural checks say the bytes are well-formed and internally consistent.
|
|
5
|
+
Only Packet Tracer itself can say the file actually opens. The two are kept
|
|
6
|
+
apart deliberately, because conflating them is how a repo ends up claiming
|
|
7
|
+
runtime proof it never had.
|
|
8
|
+
|
|
9
|
+
Tier 1 `structural_check` is headless, deterministic and safe for CI.
|
|
10
|
+
Tier 2 `open_check` launches Packet Tracer and watches for the file's window.
|
|
11
|
+
It replaces the previous `validate_open`, which called `subprocess.Popen` and
|
|
12
|
+
immediately printed `{"status": "launched"}` without observing anything at all.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
import xml.etree.ElementTree as ET
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from packet_tracer_env import (
|
|
29
|
+
donor_compatibility,
|
|
30
|
+
donor_tier_is_accepted,
|
|
31
|
+
get_packet_tracer_exe,
|
|
32
|
+
get_packet_tracer_target_version,
|
|
33
|
+
)
|
|
34
|
+
from pkt_codec import decode_pkt_auto, parse_pkt_xml
|
|
35
|
+
|
|
36
|
+
DEFAULT_OPEN_TIMEOUT_SECONDS = 150
|
|
37
|
+
POLL_INTERVAL_SECONDS = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class StructuralReport:
|
|
42
|
+
passed: bool
|
|
43
|
+
pkt_path: str
|
|
44
|
+
version: str = ""
|
|
45
|
+
compatibility_tier: str = ""
|
|
46
|
+
container: str = ""
|
|
47
|
+
device_count: int = 0
|
|
48
|
+
link_count: int = 0
|
|
49
|
+
failures: list[str] = field(default_factory=list)
|
|
50
|
+
warnings: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"tier": "structural",
|
|
55
|
+
"passed": self.passed,
|
|
56
|
+
"pkt": self.pkt_path,
|
|
57
|
+
"version": self.version,
|
|
58
|
+
"compatibility_tier": self.compatibility_tier,
|
|
59
|
+
"container": self.container,
|
|
60
|
+
"device_count": self.device_count,
|
|
61
|
+
"link_count": self.link_count,
|
|
62
|
+
"failures": self.failures,
|
|
63
|
+
"warnings": self.warnings,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class OpenReport:
|
|
69
|
+
# opened | refused | timeout | process_exited | packet_tracer_missing.
|
|
70
|
+
# `refused` is Packet Tracer's own answer -- the incompatible-file dialog --
|
|
71
|
+
# and is worth distinguishing from `timeout`, which only means nothing was
|
|
72
|
+
# observed in the time allowed.
|
|
73
|
+
status: str
|
|
74
|
+
pkt_path: str
|
|
75
|
+
elapsed_seconds: float = 0.0
|
|
76
|
+
observed_title: str = ""
|
|
77
|
+
detail: str = ""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def opened(self) -> bool:
|
|
81
|
+
return self.status == "opened"
|
|
82
|
+
|
|
83
|
+
def to_json(self) -> dict[str, object]:
|
|
84
|
+
return {
|
|
85
|
+
"tier": "open",
|
|
86
|
+
"status": self.status,
|
|
87
|
+
"opened": self.opened,
|
|
88
|
+
"pkt": self.pkt_path,
|
|
89
|
+
"elapsed_seconds": round(self.elapsed_seconds, 1),
|
|
90
|
+
"observed_title": self.observed_title,
|
|
91
|
+
"detail": self.detail,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def structural_check(pkt_path: str | Path, expected_devices: int | None = None) -> StructuralReport:
|
|
96
|
+
path = Path(pkt_path)
|
|
97
|
+
report = StructuralReport(passed=False, pkt_path=str(path))
|
|
98
|
+
|
|
99
|
+
if not path.exists():
|
|
100
|
+
report.failures.append(f"file does not exist: {path}")
|
|
101
|
+
return report
|
|
102
|
+
if path.stat().st_size == 0:
|
|
103
|
+
report.failures.append("file is empty")
|
|
104
|
+
return report
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
xml_bytes, container = decode_pkt_auto(path.read_bytes())
|
|
108
|
+
report.container = container
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
report.failures.append(f"decode failed: {exc}")
|
|
111
|
+
return report
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
root = parse_pkt_xml(xml_bytes)
|
|
115
|
+
except ET.ParseError as exc:
|
|
116
|
+
report.failures.append(f"XML does not parse: {exc}")
|
|
117
|
+
return report
|
|
118
|
+
|
|
119
|
+
if root.tag != "PACKETTRACER5":
|
|
120
|
+
report.failures.append(f"unexpected root element: {root.tag}")
|
|
121
|
+
|
|
122
|
+
report.version = root.findtext("./VERSION") or ""
|
|
123
|
+
if not report.version:
|
|
124
|
+
report.failures.append("no <VERSION> element")
|
|
125
|
+
else:
|
|
126
|
+
target = get_packet_tracer_target_version()
|
|
127
|
+
report.compatibility_tier = donor_compatibility(report.version, target)
|
|
128
|
+
if not donor_tier_is_accepted(report.compatibility_tier):
|
|
129
|
+
report.failures.append(
|
|
130
|
+
f"version {report.version} is tier '{report.compatibility_tier}' against target {target}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
134
|
+
links = root.findall(".//LINKS/LINK")
|
|
135
|
+
report.device_count = len(devices)
|
|
136
|
+
report.link_count = len(links)
|
|
137
|
+
|
|
138
|
+
if not devices:
|
|
139
|
+
report.failures.append("no devices")
|
|
140
|
+
|
|
141
|
+
names: list[str] = []
|
|
142
|
+
ref_to_name: dict[str, str] = {}
|
|
143
|
+
for device in devices:
|
|
144
|
+
name = device.findtext("./ENGINE/NAME") or ""
|
|
145
|
+
if not name:
|
|
146
|
+
report.failures.append("a device has no name")
|
|
147
|
+
continue
|
|
148
|
+
names.append(name)
|
|
149
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID") or ""
|
|
150
|
+
if save_ref:
|
|
151
|
+
ref_to_name[save_ref] = name
|
|
152
|
+
|
|
153
|
+
duplicates = {name for name in names if names.count(name) > 1}
|
|
154
|
+
if duplicates:
|
|
155
|
+
report.failures.append(f"duplicate device names: {sorted(duplicates)}")
|
|
156
|
+
|
|
157
|
+
# Every link must land on a device that exists. A dangling endpoint is the
|
|
158
|
+
# classic way a pruned donor stops opening.
|
|
159
|
+
#
|
|
160
|
+
# Ports must also be used at most once: one physical interface cannot carry
|
|
161
|
+
# two cables. A generated link that reuses an occupied port produces a lab
|
|
162
|
+
# that looks plausible and is wired wrongly.
|
|
163
|
+
occupied_ports: dict[tuple[str, str], int] = {}
|
|
164
|
+
for index, link in enumerate(links):
|
|
165
|
+
cable = link.find("./CABLE")
|
|
166
|
+
if cable is None:
|
|
167
|
+
report.failures.append(f"link {index} has no CABLE element")
|
|
168
|
+
continue
|
|
169
|
+
endpoints: list[str] = []
|
|
170
|
+
for end in ("FROM", "TO"):
|
|
171
|
+
ref = cable.findtext(end) or ""
|
|
172
|
+
if not ref:
|
|
173
|
+
report.failures.append(f"link {index} has an empty {end} reference")
|
|
174
|
+
elif ref not in ref_to_name:
|
|
175
|
+
report.failures.append(f"link {index} {end} references unknown device {ref!r}")
|
|
176
|
+
else:
|
|
177
|
+
endpoints.append(ref_to_name[ref])
|
|
178
|
+
|
|
179
|
+
ports = [(port.text or "").strip() for port in cable.findall("PORT")][:2]
|
|
180
|
+
for device_name, port_name in zip(endpoints, ports):
|
|
181
|
+
if not port_name:
|
|
182
|
+
continue
|
|
183
|
+
key = (device_name, port_name)
|
|
184
|
+
previous = occupied_ports.get(key)
|
|
185
|
+
if previous is not None:
|
|
186
|
+
report.failures.append(
|
|
187
|
+
f"port {device_name} {port_name} is used by both link {previous} and link {index}"
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
occupied_ports[key] = index
|
|
191
|
+
|
|
192
|
+
if expected_devices is not None and report.device_count != expected_devices:
|
|
193
|
+
report.warnings.append(
|
|
194
|
+
f"device count is {report.device_count}, plan expected {expected_devices}; "
|
|
195
|
+
"donor spares are parked rather than deleted"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
report.passed = not report.failures
|
|
199
|
+
return report
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Packet Tracer's own title for the dialog it shows when it will not load a
|
|
203
|
+
# file. Detecting it turns a 150-second timeout into an immediate, correctly
|
|
204
|
+
# named answer, which is the difference between a corpus run that takes eighty
|
|
205
|
+
# minutes to say nothing and one that says which labs are refused.
|
|
206
|
+
REFUSAL_WINDOW_TITLE = "Incompatible File"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _top_level_window_titles() -> list[str]:
|
|
210
|
+
"""Every visible top-level window title on the desktop.
|
|
211
|
+
|
|
212
|
+
`MainWindowTitle` reports one window per process, and Packet Tracer moves
|
|
213
|
+
which of its windows holds that role: the same open showed only the
|
|
214
|
+
extension's log window in one run and the loaded document in the next. It
|
|
215
|
+
also never shows the modal refusal dialog, so a refused file was
|
|
216
|
+
indistinguishable from a slow one. Enumerating every window is what let
|
|
217
|
+
"opened" and "refused" be told apart at all.
|
|
218
|
+
"""
|
|
219
|
+
if platform.system() != "Windows":
|
|
220
|
+
return []
|
|
221
|
+
import ctypes
|
|
222
|
+
from ctypes import wintypes
|
|
223
|
+
|
|
224
|
+
user32 = ctypes.windll.user32
|
|
225
|
+
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
226
|
+
titles: list[str] = []
|
|
227
|
+
|
|
228
|
+
def collect(hwnd, _lparam): # pragma: no cover - GUI enumeration
|
|
229
|
+
if not user32.IsWindowVisible(hwnd):
|
|
230
|
+
return True
|
|
231
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
232
|
+
if length <= 0:
|
|
233
|
+
return True
|
|
234
|
+
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
235
|
+
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
|
236
|
+
text = buffer.value.strip()
|
|
237
|
+
if text:
|
|
238
|
+
titles.append(text)
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
user32.EnumWindows(callback_type(collect), 0)
|
|
243
|
+
except OSError: # pragma: no cover - defensive
|
|
244
|
+
return []
|
|
245
|
+
return titles
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _dismiss_refusal_dialogs() -> int:
|
|
249
|
+
"""Close any incompatible-file dialog left on screen, returning how many.
|
|
250
|
+
|
|
251
|
+
The dialog is modal: while one is up Packet Tracer will not load anything
|
|
252
|
+
else, so a batch run would report every lab after the first refusal as
|
|
253
|
+
refused too -- and instantly, since the dialog is already there. Clearing it
|
|
254
|
+
before each launch is what makes a sequence of checks mean anything.
|
|
255
|
+
"""
|
|
256
|
+
if platform.system() != "Windows":
|
|
257
|
+
return 0
|
|
258
|
+
import ctypes
|
|
259
|
+
from ctypes import wintypes
|
|
260
|
+
|
|
261
|
+
user32 = ctypes.windll.user32
|
|
262
|
+
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
263
|
+
handles: list[int] = []
|
|
264
|
+
|
|
265
|
+
def collect(hwnd, _lparam): # pragma: no cover - GUI enumeration
|
|
266
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
267
|
+
if length <= 0:
|
|
268
|
+
return True
|
|
269
|
+
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
270
|
+
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
|
271
|
+
if REFUSAL_WINDOW_TITLE in buffer.value:
|
|
272
|
+
handles.append(hwnd)
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
user32.EnumWindows(callback_type(collect), 0)
|
|
277
|
+
for hwnd in handles:
|
|
278
|
+
user32.PostMessageW(hwnd, 0x0010, 0, 0) # WM_CLOSE
|
|
279
|
+
except OSError: # pragma: no cover - defensive
|
|
280
|
+
return 0
|
|
281
|
+
if handles:
|
|
282
|
+
time.sleep(1.5)
|
|
283
|
+
return len(handles)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _windows_titles_matching(stem: str) -> str:
|
|
287
|
+
for title in _top_level_window_titles():
|
|
288
|
+
if stem.lower() in title.lower():
|
|
289
|
+
return title
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_PROBE_COUNTER = itertools.count(1)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _unique_probe_copy(path: Path) -> Path:
|
|
297
|
+
"""A copy of the lab under a name no window can already be showing.
|
|
298
|
+
|
|
299
|
+
Kept beside the original on purpose: labs reference their artwork with
|
|
300
|
+
relative paths such as `../art/Background/grid_100x100.png`, so a copy in a
|
|
301
|
+
temp directory would be a different file in a way that matters.
|
|
302
|
+
"""
|
|
303
|
+
probe = path.with_name(f"{path.stem}__probe{os.getpid()}_{next(_PROBE_COUNTER)}{path.suffix}")
|
|
304
|
+
try:
|
|
305
|
+
shutil.copyfile(path, probe)
|
|
306
|
+
except OSError: # pragma: no cover - falls back to checking the file itself
|
|
307
|
+
return path
|
|
308
|
+
return probe
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _discard_probe_copy(probe: Path, original: Path) -> None:
|
|
312
|
+
if probe == original:
|
|
313
|
+
return
|
|
314
|
+
try:
|
|
315
|
+
probe.unlink()
|
|
316
|
+
except OSError: # pragma: no cover - Packet Tracer may still hold it open
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def open_check(
|
|
321
|
+
pkt_path: str | Path,
|
|
322
|
+
timeout_seconds: int = DEFAULT_OPEN_TIMEOUT_SECONDS,
|
|
323
|
+
attempts: int = 2,
|
|
324
|
+
) -> OpenReport:
|
|
325
|
+
"""Launch Packet Tracer and wait until the file's window appears.
|
|
326
|
+
|
|
327
|
+
A negative verdict has to reproduce before it is reported. Measured: a
|
|
328
|
+
bisect over 57 plan operations called one step `refused`, and the same file
|
|
329
|
+
re-checked five times afterwards opened five times out of five. The dialog
|
|
330
|
+
Packet Tracer raises for the *previous* probe is a window that did not
|
|
331
|
+
exist when this check started, so it was counted against the wrong file.
|
|
332
|
+
An open is believed on the first try; anything else is tried again, because
|
|
333
|
+
a false refusal sends the search after a defect that is not there.
|
|
334
|
+
"""
|
|
335
|
+
attempts = max(1, attempts)
|
|
336
|
+
report = _open_check_once(pkt_path, timeout_seconds)
|
|
337
|
+
for _ in range(attempts - 1):
|
|
338
|
+
if report.status == "opened":
|
|
339
|
+
return report
|
|
340
|
+
report = _open_check_once(pkt_path, timeout_seconds)
|
|
341
|
+
return report
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _open_check_once(
|
|
345
|
+
pkt_path: str | Path,
|
|
346
|
+
timeout_seconds: int = DEFAULT_OPEN_TIMEOUT_SECONDS,
|
|
347
|
+
) -> OpenReport:
|
|
348
|
+
"""One launch and one verdict.
|
|
349
|
+
|
|
350
|
+
Window-title observation is currently implemented for Windows. On other
|
|
351
|
+
hosts the launch still happens and process liveness is reported, which is
|
|
352
|
+
weaker evidence and is labelled as such rather than claimed as an open.
|
|
353
|
+
"""
|
|
354
|
+
path = Path(pkt_path).resolve()
|
|
355
|
+
report = OpenReport(status="packet_tracer_missing", pkt_path=str(path))
|
|
356
|
+
|
|
357
|
+
executable = get_packet_tracer_exe()
|
|
358
|
+
if executable is None:
|
|
359
|
+
report.detail = "no Packet Tracer executable resolved; set PACKET_TRACER_ROOT"
|
|
360
|
+
return report
|
|
361
|
+
if not path.exists():
|
|
362
|
+
report.status = "process_exited"
|
|
363
|
+
report.detail = f"file does not exist: {path}"
|
|
364
|
+
return report
|
|
365
|
+
|
|
366
|
+
_dismiss_refusal_dialogs()
|
|
367
|
+
# A window already showing this file is not evidence that this check opened
|
|
368
|
+
# it. Re-checking a lab Packet Tracer still had on screen reported `opened`
|
|
369
|
+
# in 0.0 seconds without loading anything -- the same shape of false
|
|
370
|
+
# positive that let unopenable labs pass for months. Anything present before
|
|
371
|
+
# the launch is excluded from counting as a result.
|
|
372
|
+
#
|
|
373
|
+
# Excluding it costs the opposite error, though: with the old window still
|
|
374
|
+
# up, the title this check is waiting for is already excluded, so the check
|
|
375
|
+
# goes blind to its own success and times out. Measured on one lab checked
|
|
376
|
+
# five times in a row: opened, timeout, timeout, opened, opened. Opening a
|
|
377
|
+
# uniquely named copy sidesteps both -- the title cannot collide with
|
|
378
|
+
# anything already on screen, so no window has to be closed or guessed at.
|
|
379
|
+
probe = _unique_probe_copy(path)
|
|
380
|
+
titles_before = set(_top_level_window_titles())
|
|
381
|
+
started = time.monotonic()
|
|
382
|
+
try:
|
|
383
|
+
process = subprocess.Popen([str(executable), str(probe)])
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
report.status = "process_exited"
|
|
386
|
+
report.detail = f"could not launch Packet Tracer: {exc}"
|
|
387
|
+
return report
|
|
388
|
+
|
|
389
|
+
is_windows = platform.system() == "Windows"
|
|
390
|
+
try:
|
|
391
|
+
while time.monotonic() - started < timeout_seconds:
|
|
392
|
+
if is_windows:
|
|
393
|
+
titles = [
|
|
394
|
+
entry for entry in _top_level_window_titles() if entry not in titles_before
|
|
395
|
+
]
|
|
396
|
+
title = next((entry for entry in titles if probe.stem.lower() in entry.lower()), "")
|
|
397
|
+
if title:
|
|
398
|
+
report.status = "opened"
|
|
399
|
+
report.observed_title = title
|
|
400
|
+
report.elapsed_seconds = time.monotonic() - started
|
|
401
|
+
report.detail = "Packet Tracer window title contains the file name"
|
|
402
|
+
return report
|
|
403
|
+
refusal = next((entry for entry in titles if REFUSAL_WINDOW_TITLE in entry), "")
|
|
404
|
+
if refusal:
|
|
405
|
+
report.status = "refused"
|
|
406
|
+
report.observed_title = refusal
|
|
407
|
+
report.elapsed_seconds = time.monotonic() - started
|
|
408
|
+
report.detail = "Packet Tracer put up its incompatible-file dialog"
|
|
409
|
+
return report
|
|
410
|
+
|
|
411
|
+
# Checked after the windows, not before. Packet Tracer runs one
|
|
412
|
+
# instance: launching it again while a copy is open hands the file
|
|
413
|
+
# over and the new process exits at once, which this read as a
|
|
414
|
+
# failure while the file was loading perfectly well behind it.
|
|
415
|
+
if process.poll() is not None and not is_windows:
|
|
416
|
+
report.status = "process_exited"
|
|
417
|
+
report.elapsed_seconds = time.monotonic() - started
|
|
418
|
+
report.detail = f"Packet Tracer exited with code {process.returncode} before the file opened"
|
|
419
|
+
return report
|
|
420
|
+
|
|
421
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
422
|
+
|
|
423
|
+
report.elapsed_seconds = time.monotonic() - started
|
|
424
|
+
if is_windows:
|
|
425
|
+
report.status = "timeout"
|
|
426
|
+
report.detail = f"no Packet Tracer window titled with {probe.stem!r} within {timeout_seconds}s"
|
|
427
|
+
else:
|
|
428
|
+
report.status = "timeout"
|
|
429
|
+
report.detail = (
|
|
430
|
+
"window-title observation is Windows-only; the process stayed alive but the open "
|
|
431
|
+
"was not confirmed. Check manually on this host."
|
|
432
|
+
)
|
|
433
|
+
return report
|
|
434
|
+
finally:
|
|
435
|
+
if process.poll() is None:
|
|
436
|
+
process.terminate()
|
|
437
|
+
_discard_probe_copy(probe, path)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def main() -> int:
|
|
441
|
+
import argparse
|
|
442
|
+
|
|
443
|
+
parser = argparse.ArgumentParser(description="Verify a generated Packet Tracer file.")
|
|
444
|
+
parser.add_argument("pkt", help="path to the .pkt file")
|
|
445
|
+
parser.add_argument("--open", action="store_true", help="also launch Packet Tracer and watch for the window")
|
|
446
|
+
parser.add_argument("--timeout", type=int, default=DEFAULT_OPEN_TIMEOUT_SECONDS)
|
|
447
|
+
parser.add_argument("--expect-devices", type=int, default=None)
|
|
448
|
+
args = parser.parse_args()
|
|
449
|
+
|
|
450
|
+
structural = structural_check(args.pkt, args.expect_devices)
|
|
451
|
+
payload: dict[str, object] = {"structural": structural.to_json()}
|
|
452
|
+
|
|
453
|
+
if args.open:
|
|
454
|
+
payload["open"] = open_check(args.pkt, args.timeout).to_json()
|
|
455
|
+
|
|
456
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
457
|
+
return 0 if structural.passed else 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
raise SystemExit(main())
|