robotframework-snapshot 0.1.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.
- SnapshotLibrary/__init__.py +6 -0
- SnapshotLibrary/__main__.py +77 -0
- SnapshotLibrary/core.py +149 -0
- SnapshotLibrary/jsonpath.py +96 -0
- SnapshotLibrary/library.py +519 -0
- SnapshotLibrary/normalizers.py +92 -0
- SnapshotLibrary/py.typed +0 -0
- SnapshotLibrary/serializers.py +148 -0
- SnapshotLibrary/store.py +60 -0
- SnapshotLibrary/unused.py +191 -0
- SnapshotLibrary/version.py +1 -0
- SnapshotLibrary/xmlpath.py +74 -0
- robotframework_snapshot-0.1.0.dist-info/METADATA +432 -0
- robotframework_snapshot-0.1.0.dist-info/RECORD +16 -0
- robotframework_snapshot-0.1.0.dist-info/WHEEL +4 -0
- robotframework_snapshot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from robot.api import logger
|
|
10
|
+
from robot.api.deco import keyword, library
|
|
11
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
12
|
+
from robot.utils import is_truthy
|
|
13
|
+
|
|
14
|
+
from . import core, normalizers as normalizing, serializers, store, unused as usage_tracking
|
|
15
|
+
from .core import Outcome
|
|
16
|
+
from .version import __version__
|
|
17
|
+
|
|
18
|
+
ACTUAL_DIR_NAME = "snapshot_actual"
|
|
19
|
+
SCOPES = ("test", "suite", "global")
|
|
20
|
+
# The clickable line above a folded diff, styled as a button that works on the light and the dark log theme.
|
|
21
|
+
SUMMARY_STYLE = (
|
|
22
|
+
"display:list-item; list-style-position:inside; width:fit-content; cursor:pointer; "
|
|
23
|
+
"padding:3px 10px; border:1px solid #8c959f; border-radius:6px; "
|
|
24
|
+
"background:rgba(140,149,159,0.15); font-weight:bold"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@library(scope="GLOBAL", version=__version__, doc_format="ROBOT")
|
|
29
|
+
class SnapshotLibrary:
|
|
30
|
+
"""Checks text and data against a stored expected value, without writing that value by hand.
|
|
31
|
+
|
|
32
|
+
The first time a test runs, the value is saved to a file next to the
|
|
33
|
+
suite. Every run after that compares against the file and fails with a
|
|
34
|
+
diff when something changed.
|
|
35
|
+
|
|
36
|
+
| *** Settings ***
|
|
37
|
+
| Library Process
|
|
38
|
+
| Library SnapshotLibrary
|
|
39
|
+
|
|
|
40
|
+
| *** Test Cases ***
|
|
41
|
+
| Help Text Is Stable
|
|
42
|
+
| ${result}= Run Process mytool --help
|
|
43
|
+
| Should Match Snapshot ${result.stdout}
|
|
44
|
+
|
|
45
|
+
The first run writes ``__snapshots__/<suite file name>/Help_Text_Is_Stable.txt``
|
|
46
|
+
and passes with a warning. Check the file and commit it with your tests.
|
|
47
|
+
From then on the test fails when the output changes, and the failure
|
|
48
|
+
message shows a diff. When the change is intended, run once with
|
|
49
|
+
``--variable REFERENCE_RUN:True`` to overwrite the snapshot.
|
|
50
|
+
|
|
51
|
+
= Modes =
|
|
52
|
+
|
|
53
|
+
| =Mode= | =How to switch it on= | =Snapshot file missing= | =Value differs from the file= |
|
|
54
|
+
| Default | nothing | Recorded, test passes with a warning | Test fails with a diff |
|
|
55
|
+
| Update | ``--variable REFERENCE_RUN:True`` | Recorded | File is overwritten, test passes |
|
|
56
|
+
| Strict | ``--variable SNAPSHOT_STRICT:True`` or ``strict=True`` | Test fails | Test fails with a diff |
|
|
57
|
+
|
|
58
|
+
Use strict mode in CI, so a snapshot that was never committed fails the
|
|
59
|
+
build instead of being recorded on the build machine.
|
|
60
|
+
|
|
61
|
+
The failure message shows as much of the diff as fits within
|
|
62
|
+
``--maxerrorlines``; the log has the full diff in colour. With
|
|
63
|
+
``save_actual=True`` or ``--variable SNAPSHOT_SAVE_ACTUAL:True`` the full
|
|
64
|
+
actual value is also written to ``${OUTPUT_DIR}/snapshot_actual/``.
|
|
65
|
+
|
|
66
|
+
= How snapshot files are found =
|
|
67
|
+
|
|
68
|
+
The location follows from where the keyword is called, there is no
|
|
69
|
+
setting per file:
|
|
70
|
+
``<folder of the suite file>/__snapshots__/<suite file name>/<test name>.<extension>``.
|
|
71
|
+
|
|
72
|
+
- Text is stored as ``.txt``, dictionaries and lists as ``.json`` with
|
|
73
|
+
sorted keys, XML as ``.xml`` with sorted attributes. A list of rows is
|
|
74
|
+
written one row per line.
|
|
75
|
+
- A second snapshot in the same test gets the suffix ``__2``, a third
|
|
76
|
+
``__3``, or ``__<name>`` when ``name=`` is given.
|
|
77
|
+
- With ``shared=True`` the file is ``<name>.<extension>`` without the test
|
|
78
|
+
name, so several tests compare against one file.
|
|
79
|
+
- A snapshot taken in a suite setup or teardown is stored as ``__suite__``.
|
|
80
|
+
- ``snapshot_directory`` on import, or `Set Snapshot Directory`, puts all
|
|
81
|
+
suite folders in one directory instead.
|
|
82
|
+
|
|
83
|
+
= Values that change every run =
|
|
84
|
+
|
|
85
|
+
Normalizers replace changing text with a placeholder before comparing and
|
|
86
|
+
before recording, for example ``2026-03-14 09:26:53`` with
|
|
87
|
+
``<TIMESTAMP>``. Built in: ``timestamp``, ``timezone``, ``uuid``,
|
|
88
|
+
``duration`` and ``path``. Add your own with `Add Snapshot Normalizer`.
|
|
89
|
+
|
|
90
|
+
For JSON, ``ignore=`` masks fields by JSONPath (``$.id``,
|
|
91
|
+
``$..updated_at``), and for XML by XPath (``.//created``, ``@id``).
|
|
92
|
+
|
|
93
|
+
= Unused snapshots =
|
|
94
|
+
|
|
95
|
+
When a test is renamed or removed, its snapshot file stays behind. At the
|
|
96
|
+
end of each suite the library warns about files in that suite's snapshot
|
|
97
|
+
directory that no test used. It only does so when every test of the suite
|
|
98
|
+
ran and passed, because a filtered, skipped or failed test would make its
|
|
99
|
+
snapshot look unused. Turn the warning off with ``warn_unused=False``.
|
|
100
|
+
|
|
101
|
+
A parallel run with ``pabot --testlevelsplit`` runs each test in its own
|
|
102
|
+
process, so no process sees a whole suite. Check such a run afterwards,
|
|
103
|
+
from the command line:
|
|
104
|
+
|
|
105
|
+
| python -m SnapshotLibrary unused <output directory>
|
|
106
|
+
| python -m SnapshotLibrary unused <output directory> --delete
|
|
107
|
+
|
|
108
|
+
The command exits with 1 when it finds unused snapshots, so it can fail a
|
|
109
|
+
build. It also reports snapshot directories whose suite file is gone.
|
|
110
|
+
|
|
111
|
+
= Images, PDFs and screenshots =
|
|
112
|
+
|
|
113
|
+
This library does not compare visual content. Use
|
|
114
|
+
[https://github.com/manykarim/robotframework-doctestlibrary|DocTestLibrary]
|
|
115
|
+
for that. Both libraries read the same ``REFERENCE_RUN`` variable, so one
|
|
116
|
+
run updates text snapshots and visual baselines together.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
ROBOT_LISTENER_API_VERSION = 3
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
snapshot_directory: Optional[str] = None,
|
|
124
|
+
normalizers: Optional[Union[str, List[str]]] = None,
|
|
125
|
+
strict: bool = False,
|
|
126
|
+
warn_unused: bool = True,
|
|
127
|
+
save_actual: bool = False,
|
|
128
|
+
):
|
|
129
|
+
"""
|
|
130
|
+
| =Argument= | =Description= |
|
|
131
|
+
| ``snapshot_directory`` | Directory for all snapshots. Relative paths resolve against ``${EXECDIR}``. Default: ``__snapshots__`` next to each suite file. |
|
|
132
|
+
| ``normalizers`` | Built-in normalizers applied to every snapshot, comma separated, for example ``timestamp,uuid``. |
|
|
133
|
+
| ``strict`` | Fail when a snapshot is missing instead of recording it. Can also be set per run with ``--variable SNAPSHOT_STRICT:True``. |
|
|
134
|
+
| ``warn_unused`` | Warn at the end of a suite about snapshot files no test used. See `Unused snapshots`. |
|
|
135
|
+
| ``save_actual`` | When a snapshot does not match, also write the actual value to ``${OUTPUT_DIR}/snapshot_actual/``. Can also be set per run with ``--variable SNAPSHOT_SAVE_ACTUAL:True``. |
|
|
136
|
+
"""
|
|
137
|
+
# The library is its own listener (API v3), to know when tests and suites start and end.
|
|
138
|
+
self.ROBOT_LIBRARY_LISTENER = self
|
|
139
|
+
self.snapshot_directory = snapshot_directory
|
|
140
|
+
self.strict = is_truthy(strict)
|
|
141
|
+
self.warn_unused = is_truthy(warn_unused)
|
|
142
|
+
self.save_actual = is_truthy(save_actual)
|
|
143
|
+
self._usage: List[usage_tracking.SuiteUsage] = []
|
|
144
|
+
self._global_normalizers = [normalizing.builtin(name) for name in normalizing.split_names(normalizers)]
|
|
145
|
+
self._suite_normalizers: List[List[normalizing.Normalizer]] = []
|
|
146
|
+
self._test_normalizers: List[normalizing.Normalizer] = []
|
|
147
|
+
self._unnamed_count = 0
|
|
148
|
+
|
|
149
|
+
# -- listener: scopes and per-test numbering ---------------------------
|
|
150
|
+
|
|
151
|
+
def _start_suite(self, data, result):
|
|
152
|
+
self._suite_normalizers.append([])
|
|
153
|
+
self._unnamed_count = 0
|
|
154
|
+
if data.parent is None:
|
|
155
|
+
output_dir = self._variable("${OUTPUT DIR}")
|
|
156
|
+
if output_dir:
|
|
157
|
+
usage_tracking.clear_records(output_dir)
|
|
158
|
+
self._usage.append(usage_tracking.SuiteUsage(str(data.source or ""), self._count_tests(data.source)))
|
|
159
|
+
|
|
160
|
+
def _end_suite(self, data, result):
|
|
161
|
+
if self._suite_normalizers:
|
|
162
|
+
self._suite_normalizers.pop()
|
|
163
|
+
if self._usage:
|
|
164
|
+
self._close_usage(self._usage.pop(), data, result)
|
|
165
|
+
|
|
166
|
+
def _start_test(self, data, result):
|
|
167
|
+
self._test_normalizers = []
|
|
168
|
+
self._unnamed_count = 0
|
|
169
|
+
|
|
170
|
+
def _end_test(self, data, result):
|
|
171
|
+
self._test_normalizers = []
|
|
172
|
+
self._unnamed_count = 0
|
|
173
|
+
if self._usage:
|
|
174
|
+
self._usage[-1].ran[result.name] = result.status
|
|
175
|
+
|
|
176
|
+
# -- unused snapshots ----------------------------------------------------
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _count_tests(source) -> Optional[int]:
|
|
180
|
+
"""Number of tests the suite file defines, to notice a filtered run."""
|
|
181
|
+
if not source:
|
|
182
|
+
return None
|
|
183
|
+
source = Path(source)
|
|
184
|
+
if source.is_dir():
|
|
185
|
+
return 0
|
|
186
|
+
try:
|
|
187
|
+
from robot.api import TestSuite
|
|
188
|
+
|
|
189
|
+
return len(TestSuite.from_file_system(str(source)).tests)
|
|
190
|
+
except Exception: # an unreadable suite must never break the run
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def _is_dry_run(self) -> bool:
|
|
194
|
+
try:
|
|
195
|
+
from robot.running.context import EXECUTION_CONTEXTS
|
|
196
|
+
|
|
197
|
+
return bool(EXECUTION_CONTEXTS.current.dry_run)
|
|
198
|
+
except Exception:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def _close_usage(self, usage, data, result):
|
|
202
|
+
if not usage.source or self._is_dry_run():
|
|
203
|
+
return
|
|
204
|
+
own_tests_only = Path(usage.source).is_file()
|
|
205
|
+
# A directory suite fails when any test below it fails; that says nothing
|
|
206
|
+
# about its own snapshots, but there is no cheaper safe signal.
|
|
207
|
+
usage.suite_passed = True if own_tests_only else bool(result.passed)
|
|
208
|
+
# has_setup first: a suite without setup still has a placeholder object whose status is FAIL
|
|
209
|
+
if own_tests_only and (
|
|
210
|
+
(result.has_setup and result.setup.failed) or (result.has_teardown and result.teardown.failed)
|
|
211
|
+
):
|
|
212
|
+
usage.suite_passed = False
|
|
213
|
+
default_directory = self._snapshot_base(Path(usage.source)) / store.sanitize(self._suite_folder(Path(usage.source)))
|
|
214
|
+
usage.directories.add(str(default_directory))
|
|
215
|
+
output_dir = self._variable("${OUTPUT DIR}")
|
|
216
|
+
if output_dir:
|
|
217
|
+
try:
|
|
218
|
+
usage_tracking.write_record(output_dir, usage)
|
|
219
|
+
except OSError as error:
|
|
220
|
+
logger.debug(f"Could not write the snapshot usage record: {error}")
|
|
221
|
+
if self.warn_unused and usage.complete:
|
|
222
|
+
unused = usage.unused()
|
|
223
|
+
if unused:
|
|
224
|
+
listing = ", ".join(self._label(path) for path in unused)
|
|
225
|
+
logger.warn(
|
|
226
|
+
f"Unused snapshot{'s' if len(unused) > 1 else ''}: {listing}. "
|
|
227
|
+
"No test used them in this run. Delete them if they are no longer needed."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _mark_used(self, path: Path):
|
|
231
|
+
if self._usage:
|
|
232
|
+
self._usage[-1].used.add(str(path))
|
|
233
|
+
self._usage[-1].directories.add(str(path.parent))
|
|
234
|
+
|
|
235
|
+
# -- keywords ----------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
@keyword
|
|
238
|
+
def should_match_snapshot(
|
|
239
|
+
self,
|
|
240
|
+
value: Any,
|
|
241
|
+
name: Optional[str] = None,
|
|
242
|
+
ignore: Optional[Union[str, List[str]]] = None,
|
|
243
|
+
normalizers: Optional[Union[str, List[str]]] = None,
|
|
244
|
+
format: str = "auto",
|
|
245
|
+
shared: bool = False,
|
|
246
|
+
):
|
|
247
|
+
"""Compares ``value`` with its stored snapshot.
|
|
248
|
+
|
|
249
|
+
If the snapshot does not exist yet it is recorded and the keyword
|
|
250
|
+
passes with a warning. See `Modes` for update and strict
|
|
251
|
+
mode.
|
|
252
|
+
|
|
253
|
+
| =Argument= | =Description= |
|
|
254
|
+
| ``value`` | A string, or anything that can be written as JSON: dictionary, list, number, boolean. |
|
|
255
|
+
| ``name`` | Name for this snapshot. Needed only to give several snapshots in one test readable file names. |
|
|
256
|
+
| ``ignore`` | Values to mask. JSONPath for structured data, for example ``$.id`` or ``$..updated_at``. XPath for XML, for example ``.//timestamp`` or ``.//order/@id``. Several paths: separate with ``;`` or pass a list. |
|
|
257
|
+
| ``normalizers`` | Extra normalizers for this call only, comma separated. |
|
|
258
|
+
| ``format`` | ``auto`` (default), ``text``, ``json`` or ``xml``. Use ``json`` or ``xml`` to store a JSON or XML string sorted and indented, so key order, attribute order and layout do not matter. XML elements, for example from the XML library, are detected automatically. |
|
|
259
|
+
| ``shared`` | Store the snapshot under ``name`` alone, without the test name, so several tests in the suite compare against the same file. Needs ``name``. |
|
|
260
|
+
|
|
261
|
+
Examples:
|
|
262
|
+
| `Should Match Snapshot` ${output}
|
|
263
|
+
| `Should Match Snapshot` ${rows} name=runs normalizers=timezone
|
|
264
|
+
| `Should Match Snapshot` ${response.json()} ignore=$.id;$..updated_at
|
|
265
|
+
| `Should Match Snapshot` ${response.text} format=json
|
|
266
|
+
| `Should Match Snapshot` ${soap_body} format=xml
|
|
267
|
+
| `Should Match Snapshot` ${help_text} name=help shared=True
|
|
268
|
+
"""
|
|
269
|
+
text, extension = core.prepare(value, format, ignore, self._active_normalizers(normalizers))
|
|
270
|
+
self._assert(text, extension, name, shared)
|
|
271
|
+
|
|
272
|
+
@keyword
|
|
273
|
+
def should_match_file_snapshot(
|
|
274
|
+
self,
|
|
275
|
+
path: str,
|
|
276
|
+
name: Optional[str] = None,
|
|
277
|
+
normalizers: Optional[Union[str, List[str]]] = None,
|
|
278
|
+
encoding: str = "UTF-8",
|
|
279
|
+
shared: bool = False,
|
|
280
|
+
format: str = "text",
|
|
281
|
+
):
|
|
282
|
+
"""Compares the text content of the file at ``path`` with its stored snapshot.
|
|
283
|
+
|
|
284
|
+
Use it for files the system under test produced: reports, exports,
|
|
285
|
+
generated configuration. The snapshot keeps the file's extension.
|
|
286
|
+
|
|
287
|
+
By default the content is compared as text. With ``format=json`` or
|
|
288
|
+
``format=xml`` it is stored sorted and indented, so key order, attribute
|
|
289
|
+
order and indentation do not matter.
|
|
290
|
+
|
|
291
|
+
Examples:
|
|
292
|
+
| `Should Match File Snapshot` ${OUTPUT_DIR}/export.csv
|
|
293
|
+
| `Should Match File Snapshot` ${TEMPDIR}/report.html normalizers=timestamp
|
|
294
|
+
| `Should Match File Snapshot` ${OUTPUT_DIR}/config.xml format=xml
|
|
295
|
+
"""
|
|
296
|
+
source = Path(path)
|
|
297
|
+
if not source.is_file():
|
|
298
|
+
raise AssertionError(f"File '{path}' does not exist.")
|
|
299
|
+
with open(source, "r", encoding=encoding, newline="") as file:
|
|
300
|
+
content = file.read()
|
|
301
|
+
if (format or "text").lower() == "auto":
|
|
302
|
+
raise ValueError("format=auto is not supported for files. Use text, json or xml.")
|
|
303
|
+
text, _ = core.prepare(content, format, None, self._active_normalizers(normalizers))
|
|
304
|
+
extension = source.suffix.lstrip(".") or serializers.TEXT
|
|
305
|
+
self._assert(text, extension, name, shared)
|
|
306
|
+
|
|
307
|
+
@keyword
|
|
308
|
+
def add_snapshot_normalizer(
|
|
309
|
+
self,
|
|
310
|
+
name: str,
|
|
311
|
+
pattern: Optional[str] = None,
|
|
312
|
+
replacement: Optional[str] = None,
|
|
313
|
+
scope: str = "suite",
|
|
314
|
+
):
|
|
315
|
+
"""Adds a normalizer that replaces changing text, such as timestamps or IDs, with a placeholder before comparing and recording.
|
|
316
|
+
|
|
317
|
+
With only ``name`` a built-in normalizer is enabled: ``timestamp``,
|
|
318
|
+
``timezone``, ``uuid``, ``duration`` or ``path``. With ``pattern`` a
|
|
319
|
+
custom one is registered; ``replacement`` defaults to ``<NAME>`` and
|
|
320
|
+
may use regular expression groups such as ``\\\\1``.
|
|
321
|
+
|
|
322
|
+
``scope`` is ``test``, ``suite`` (default) or ``global``. A test-scoped
|
|
323
|
+
normalizer is dropped when the test ends, a suite-scoped one when the
|
|
324
|
+
suite ends.
|
|
325
|
+
|
|
326
|
+
Examples:
|
|
327
|
+
| `Add Snapshot Normalizer` timestamp
|
|
328
|
+
| `Add Snapshot Normalizer` order_id pattern=ORD-\\\\d+ scope=test
|
|
329
|
+
| `Add Snapshot Normalizer` port pattern=(localhost):\\\\d+ replacement=\\\\1:<PORT>
|
|
330
|
+
"""
|
|
331
|
+
scope = scope.strip().lower()
|
|
332
|
+
if scope not in SCOPES:
|
|
333
|
+
raise ValueError(f"Unknown scope '{scope}'. Use one of: {', '.join(SCOPES)}.")
|
|
334
|
+
if pattern is None:
|
|
335
|
+
normalizer = normalizing.builtin(name)
|
|
336
|
+
else:
|
|
337
|
+
if replacement is None:
|
|
338
|
+
replacement = f"<{name.upper()}>"
|
|
339
|
+
normalizer = normalizing.regex_normalizer(name, pattern, replacement)
|
|
340
|
+
if scope == "global":
|
|
341
|
+
self._global_normalizers.append(normalizer)
|
|
342
|
+
elif scope == "test" and self._in_test():
|
|
343
|
+
self._test_normalizers.append(normalizer)
|
|
344
|
+
else:
|
|
345
|
+
if not self._suite_normalizers:
|
|
346
|
+
self._suite_normalizers.append([])
|
|
347
|
+
self._suite_normalizers[-1].append(normalizer)
|
|
348
|
+
|
|
349
|
+
@keyword
|
|
350
|
+
def set_snapshot_directory(self, snapshot_directory: Optional[str] = None) -> Optional[str]:
|
|
351
|
+
"""Changes the directory snapshots are stored in and returns the previous setting.
|
|
352
|
+
|
|
353
|
+
Relative paths resolve against ``${EXECDIR}``. Without an argument the
|
|
354
|
+
default is restored: ``__snapshots__`` next to each suite file.
|
|
355
|
+
|
|
356
|
+
Example:
|
|
357
|
+
| ${previous}= `Set Snapshot Directory` ${EXECDIR}/snapshots/staging
|
|
358
|
+
"""
|
|
359
|
+
previous = self.snapshot_directory
|
|
360
|
+
self.snapshot_directory = snapshot_directory or None
|
|
361
|
+
return previous
|
|
362
|
+
|
|
363
|
+
@keyword
|
|
364
|
+
def get_snapshot(self, name: Optional[str] = None, shared: bool = False) -> Any:
|
|
365
|
+
"""Returns the stored snapshot, for custom assertions.
|
|
366
|
+
|
|
367
|
+
JSON snapshots are returned as dictionaries or lists, everything else
|
|
368
|
+
as a string. Without ``name`` the first unnamed snapshot of the
|
|
369
|
+
current test is returned. Fails if the snapshot does not exist.
|
|
370
|
+
|
|
371
|
+
Example:
|
|
372
|
+
| ${stored}= `Get Snapshot` name=runs
|
|
373
|
+
"""
|
|
374
|
+
base = self._path(name, "*", index=1, shared=shared).with_suffix("")
|
|
375
|
+
matches = sorted(base.parent.glob(base.name + ".*")) if base.parent.is_dir() else []
|
|
376
|
+
matches = [match for match in matches if match.stem == base.name]
|
|
377
|
+
if not matches:
|
|
378
|
+
raise AssertionError(f"Snapshot '{self._label(base)}.*' does not exist.")
|
|
379
|
+
self._mark_used(matches[0])
|
|
380
|
+
text = store.read(matches[0])
|
|
381
|
+
if matches[0].suffix == "." + serializers.JSON:
|
|
382
|
+
return json.loads(text)
|
|
383
|
+
return text
|
|
384
|
+
|
|
385
|
+
# -- internals ---------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def _assert(self, text: str, extension: str, name: Optional[str], shared: bool = False):
|
|
388
|
+
if name:
|
|
389
|
+
index = 1
|
|
390
|
+
else:
|
|
391
|
+
self._unnamed_count += 1
|
|
392
|
+
index = self._unnamed_count
|
|
393
|
+
path = self._path(name, extension, index, shared)
|
|
394
|
+
self._mark_used(path)
|
|
395
|
+
label = self._label(path)
|
|
396
|
+
update = is_truthy(self._variable("${REFERENCE_RUN}", False))
|
|
397
|
+
strict = self.strict or is_truthy(self._variable("${SNAPSHOT_STRICT}", False))
|
|
398
|
+
result = core.check(path, text, update=update, strict=strict)
|
|
399
|
+
|
|
400
|
+
if result.outcome is Outcome.MATCHED:
|
|
401
|
+
logger.info(f"Snapshot '{label}' matches.")
|
|
402
|
+
elif result.outcome is Outcome.RECORDED:
|
|
403
|
+
message = f"Snapshot '{label}' did not exist and was recorded. Review and commit it."
|
|
404
|
+
if update:
|
|
405
|
+
logger.info(message)
|
|
406
|
+
else:
|
|
407
|
+
logger.warn(message)
|
|
408
|
+
elif result.outcome is Outcome.UPDATED:
|
|
409
|
+
self._log_diff(core.unified_diff(result.expected, result.actual), "Changes written to the snapshot", opened=True)
|
|
410
|
+
logger.info(f"Reference run: snapshot '{label}' was updated.")
|
|
411
|
+
elif result.outcome is Outcome.MISSING:
|
|
412
|
+
raise AssertionError(
|
|
413
|
+
f"Snapshot '{label}' does not exist and strict mode is on. "
|
|
414
|
+
"Record it locally (run without strict mode) and commit the file."
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
actual_path = None
|
|
418
|
+
if self.save_actual or is_truthy(self._variable("${SNAPSHOT_SAVE_ACTUAL}", False)):
|
|
419
|
+
actual_path = self._save_actual(path, text)
|
|
420
|
+
if actual_path:
|
|
421
|
+
logger.info(f"Actual value saved to '{actual_path}'.")
|
|
422
|
+
message, omitted = core.mismatch_message(label, result.diff, self._max_message_lines())
|
|
423
|
+
if omitted:
|
|
424
|
+
shown = len(result.diff) - omitted
|
|
425
|
+
title = f"Full diff in colour. The failure message shows the first {shown} of {len(result.diff)} lines"
|
|
426
|
+
self._log_diff(result.diff, title, opened=True)
|
|
427
|
+
else:
|
|
428
|
+
# Robot Framework already shows the whole diff in the message, so keep this one closed.
|
|
429
|
+
self._log_diff(result.diff, f"Show diff in colour ({len(result.diff)} lines)", opened=False)
|
|
430
|
+
raise AssertionError(message)
|
|
431
|
+
|
|
432
|
+
def _active_normalizers(self, extra) -> List[normalizing.Normalizer]:
|
|
433
|
+
active = list(self._global_normalizers)
|
|
434
|
+
for suite_level in self._suite_normalizers:
|
|
435
|
+
active.extend(suite_level)
|
|
436
|
+
active.extend(self._test_normalizers)
|
|
437
|
+
known = {normalizer.name: normalizer for normalizer in active}
|
|
438
|
+
for name in normalizing.split_names(extra):
|
|
439
|
+
active.append(known.get(name) or normalizing.builtin(name))
|
|
440
|
+
return active
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def _max_message_lines() -> Optional[int]:
|
|
444
|
+
"""The --maxerrorlines of the current run, None when it is NONE."""
|
|
445
|
+
try:
|
|
446
|
+
from robot.utils import text
|
|
447
|
+
|
|
448
|
+
return text.MAX_ERROR_LINES
|
|
449
|
+
except Exception: # not public API; fall back to Robot Framework's default
|
|
450
|
+
return 40
|
|
451
|
+
|
|
452
|
+
def _variable(self, name: str, default=None):
|
|
453
|
+
return BuiltIn().get_variable_value(name, default)
|
|
454
|
+
|
|
455
|
+
def _in_test(self) -> bool:
|
|
456
|
+
return bool(self._variable("${TEST NAME}"))
|
|
457
|
+
|
|
458
|
+
def _exec_dir(self) -> Path:
|
|
459
|
+
return Path(self._variable("${EXECDIR}", os.getcwd()))
|
|
460
|
+
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _suite_folder(source: Path) -> str:
|
|
463
|
+
return "__init__" if source.is_dir() else source.stem
|
|
464
|
+
|
|
465
|
+
def _snapshot_base(self, source: Optional[Path]) -> Path:
|
|
466
|
+
"""The directory that holds the per-suite snapshot folders."""
|
|
467
|
+
if self.snapshot_directory:
|
|
468
|
+
base = Path(self.snapshot_directory)
|
|
469
|
+
return base if base.is_absolute() else self._exec_dir() / base
|
|
470
|
+
if source is None:
|
|
471
|
+
return self._exec_dir() / store.SNAPSHOT_DIR_NAME
|
|
472
|
+
directory = source if source.is_dir() else source.parent
|
|
473
|
+
return directory / store.SNAPSHOT_DIR_NAME
|
|
474
|
+
|
|
475
|
+
def _path(self, name: Optional[str], extension: str, index: int, shared: bool = False) -> Path:
|
|
476
|
+
source = self._variable("${SUITE SOURCE}")
|
|
477
|
+
source = Path(source) if source else None
|
|
478
|
+
suite = self._suite_folder(source) if source else self._variable("${SUITE NAME}", "suite")
|
|
479
|
+
base = self._snapshot_base(source)
|
|
480
|
+
test = self._variable("${TEST NAME}") or "__suite__"
|
|
481
|
+
if extension == "*":
|
|
482
|
+
return store.snapshot_path(base, suite, test, "x", name, index, shared).with_suffix(".*")
|
|
483
|
+
return store.snapshot_path(base, suite, test, extension, name, index, shared)
|
|
484
|
+
|
|
485
|
+
def _label(self, path: Path) -> str:
|
|
486
|
+
try:
|
|
487
|
+
return Path(os.path.relpath(path, self._exec_dir())).as_posix()
|
|
488
|
+
except ValueError: # different drive on Windows
|
|
489
|
+
return path.as_posix()
|
|
490
|
+
|
|
491
|
+
def _save_actual(self, snapshot: Path, text: str) -> Optional[Path]:
|
|
492
|
+
output_dir = self._variable("${OUTPUT DIR}")
|
|
493
|
+
if not output_dir:
|
|
494
|
+
return None
|
|
495
|
+
target = Path(output_dir) / ACTUAL_DIR_NAME / snapshot.parent.name / snapshot.name
|
|
496
|
+
try:
|
|
497
|
+
store.write(target, text)
|
|
498
|
+
except OSError as error:
|
|
499
|
+
logger.debug(f"Could not save the actual value: {error}")
|
|
500
|
+
return None
|
|
501
|
+
return target
|
|
502
|
+
|
|
503
|
+
@staticmethod
|
|
504
|
+
def _log_diff(diff: List[str], title: str, opened: bool):
|
|
505
|
+
"""Logs the diff with removed lines red and added lines green, in a block that can be folded."""
|
|
506
|
+
if not diff:
|
|
507
|
+
return
|
|
508
|
+
styles = {"+": "color:#1a7f37", "-": "color:#cf222e", "@": "color:#6e7781"}
|
|
509
|
+
lines = []
|
|
510
|
+
for line in diff:
|
|
511
|
+
style = styles.get(line[:1], "")
|
|
512
|
+
escaped = html.escape(line)
|
|
513
|
+
lines.append(f'<span style="{style}">{escaped}</span>' if style else escaped)
|
|
514
|
+
# A box that scrolls once it is long, so a big diff does not flood the log.
|
|
515
|
+
logger.info(
|
|
516
|
+
f"<details{' open' if opened else ''}><summary style=\"{SUMMARY_STYLE}\">{html.escape(title)}</summary>"
|
|
517
|
+
'<pre style="margin:4px 0 0; max-height:500px; overflow:auto">' + "\n".join(lines) + "</pre></details>",
|
|
518
|
+
html=True,
|
|
519
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Normalizers replace changing text, such as timestamps and IDs, with fixed placeholders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Dict, Iterable, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
_STAMP = r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?"
|
|
13
|
+
_OFFSET = r"(?:Z|[+-]\d{2}:?\d{2})"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Normalizer:
|
|
18
|
+
name: str
|
|
19
|
+
apply: Callable[[str], str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def regex_normalizer(name: str, pattern: str, replacement: str) -> Normalizer:
|
|
23
|
+
try:
|
|
24
|
+
compiled = re.compile(pattern)
|
|
25
|
+
except re.error as error:
|
|
26
|
+
raise ValueError(f"Normalizer '{name}' has an invalid pattern: {error}") from None
|
|
27
|
+
return Normalizer(name, lambda text: compiled.sub(replacement, text))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _path_normalizer() -> Normalizer:
|
|
31
|
+
def apply(text: str) -> str:
|
|
32
|
+
roots: Dict[str, str] = {}
|
|
33
|
+
for label, root in (
|
|
34
|
+
("<CWD>", os.getcwd()),
|
|
35
|
+
("<TMP>", tempfile.gettempdir()),
|
|
36
|
+
("<HOME>", str(Path.home())),
|
|
37
|
+
):
|
|
38
|
+
if root and len(root) > 1:
|
|
39
|
+
roots.setdefault(root, label)
|
|
40
|
+
# Longest root first, so a temp dir inside the home dir wins over <HOME>.
|
|
41
|
+
for root in sorted(roots, key=len, reverse=True):
|
|
42
|
+
for variant in {root, root.replace("\\", "/"), root.replace("\\", "\\\\")}:
|
|
43
|
+
text = text.replace(variant, roots[root])
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
return Normalizer("path", apply)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _builtin() -> Dict[str, Normalizer]:
|
|
50
|
+
return {
|
|
51
|
+
"timestamp": regex_normalizer("timestamp", _STAMP + _OFFSET + "?", "<TIMESTAMP>"),
|
|
52
|
+
"timezone": regex_normalizer("timezone", f"({_STAMP}){_OFFSET}", r"\1<TZ>"),
|
|
53
|
+
"uuid": regex_normalizer(
|
|
54
|
+
"uuid",
|
|
55
|
+
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
|
|
56
|
+
"<UUID>",
|
|
57
|
+
),
|
|
58
|
+
"duration": regex_normalizer(
|
|
59
|
+
"duration",
|
|
60
|
+
r"\b\d+(?:\.\d+)?\s?(?:milliseconds|seconds|ms|sec|s)\b",
|
|
61
|
+
"<DURATION>",
|
|
62
|
+
),
|
|
63
|
+
"path": _path_normalizer(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
BUILTIN_NAMES = ("timestamp", "timezone", "uuid", "duration", "path")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def builtin(name: str) -> Normalizer:
|
|
71
|
+
normalizers = _builtin()
|
|
72
|
+
key = name.strip().lower()
|
|
73
|
+
if key not in normalizers:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Unknown normalizer '{name}'. Built-in normalizers: {', '.join(BUILTIN_NAMES)}. "
|
|
76
|
+
"Register your own with `Add Snapshot Normalizer`."
|
|
77
|
+
)
|
|
78
|
+
return normalizers[key]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_names(names: Optional[Union[str, Iterable[str]]]) -> List[str]:
|
|
82
|
+
if not names:
|
|
83
|
+
return []
|
|
84
|
+
if isinstance(names, str):
|
|
85
|
+
names = names.split(",")
|
|
86
|
+
return [str(name).strip() for name in names if str(name).strip()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def apply_all(text: str, normalizers: Iterable[Normalizer]) -> str:
|
|
90
|
+
for normalizer in normalizers:
|
|
91
|
+
text = normalizer.apply(text)
|
|
92
|
+
return text
|
SnapshotLibrary/py.typed
ADDED
|
File without changes
|