robotframework-events 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.
@@ -0,0 +1,30 @@
1
+ """Robot Framework execution events as JSON Lines.
2
+
3
+ Usage::
4
+
5
+ robot --listener robotframework_events.Listener tests/
6
+ robot --listener robotframework_events.Listener:file=results/events.jsonl tests/
7
+
8
+ Programs that run Robot Framework in the same process (for example robotcode's
9
+ debugger) can receive every event with :func:`add_callback`. Programs that read
10
+ events files use :func:`read_events` and :class:`RunState`.
11
+ """
12
+
13
+ from .convert import convert
14
+ from .events import Event, from_dict, schema
15
+ from .listener import Listener, add_callback, remove_callback
16
+ from .reader import read_events
17
+ from .state import RunState
18
+
19
+ __all__ = [
20
+ "Event",
21
+ "Listener",
22
+ "RunState",
23
+ "add_callback",
24
+ "convert",
25
+ "from_dict",
26
+ "read_events",
27
+ "remove_callback",
28
+ "schema",
29
+ ]
30
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,176 @@
1
+ """The ``rfevents`` command: ``tail`` shows a run in the terminal, ``convert`` builds events from a result file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import io
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections.abc import Iterable
11
+ from typing import TextIO
12
+
13
+ from . import __version__
14
+ from .convert import convert
15
+ from .events import (
16
+ Event,
17
+ MessageLogged,
18
+ RunEnded,
19
+ StepStarted,
20
+ SuiteEnded,
21
+ SuiteStarted,
22
+ TestEnded,
23
+ TestStarted,
24
+ )
25
+ from .listener import LEVELS
26
+ from .reader import read_events
27
+ from .state import RunState, step_label
28
+
29
+ COLORS = {"PASS": "32", "FAIL": "31", "SKIP": "33", "NOT RUN": "90", "WARN": "33", "ERROR": "31"}
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(prog="rfevents", description="Robot Framework execution events.")
34
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
35
+ commands = parser.add_subparsers(dest="command", required=True)
36
+
37
+ tail = commands.add_parser("tail", help="show a run from its events file, readable")
38
+ tail.add_argument("file", help="events file (.jsonl or .jsonl.gz)")
39
+ tail.add_argument("-f", "--follow", action="store_true", help="wait for new events until the run ends")
40
+ tail.add_argument(
41
+ "--steps",
42
+ type=int,
43
+ default=1,
44
+ metavar="DEPTH",
45
+ help="show steps this many levels below each test, 0 for none (default 1)",
46
+ )
47
+ tail.add_argument(
48
+ "--level",
49
+ default="WARN",
50
+ type=str.upper,
51
+ choices=list(LEVELS),
52
+ help="show log messages from this level (default WARN)",
53
+ )
54
+ tail.add_argument("--color", choices=["auto", "always", "never"], default="auto")
55
+
56
+ conv = commands.add_parser("convert", help="build events from output.xml or output.json")
57
+ conv.add_argument("output", help="output.xml or output.json")
58
+ conv.add_argument("-o", "--outfile", help="events file to write (default: standard output)")
59
+
60
+ args = parser.parse_args(argv)
61
+ if isinstance(sys.stdout, io.TextIOWrapper):
62
+ sys.stdout.reconfigure(errors="replace")
63
+ try:
64
+ if args.command == "tail":
65
+ color = args.color == "always" or (
66
+ args.color == "auto" and sys.stdout.isatty() and "NO_COLOR" not in os.environ
67
+ )
68
+ Tail(sys.stdout, args.steps, args.level, color).show(read_events(args.file, follow=args.follow))
69
+ else:
70
+ write_converted(args.output, args.outfile)
71
+ except KeyboardInterrupt:
72
+ return 130
73
+ except OutputClosed:
74
+ # keep Python from failing again when it flushes stdout at exit
75
+ sys.stdout = open(os.devnull, "w") # noqa: SIM115
76
+ return 0
77
+ except (OSError, ValueError) as e:
78
+ print(f"rfevents: {e}", file=sys.stderr)
79
+ return 2
80
+ return 0
81
+
82
+
83
+ def write_converted(output: str, outfile: str | None) -> None:
84
+ encode = json.JSONEncoder(ensure_ascii=False, separators=(",", ":")).encode
85
+ lines = (encode(event) + "\n" for event in convert(output))
86
+ if outfile is None:
87
+ sys.stdout.writelines(lines)
88
+ return
89
+ with open(outfile, "w", encoding="utf-8") as file:
90
+ file.writelines(lines)
91
+
92
+
93
+ class OutputClosed(Exception):
94
+ pass
95
+
96
+
97
+ class Tail:
98
+ """Prints suites, tests, top-level steps, failures and warnings as they happen."""
99
+
100
+ def __init__(self, out: TextIO, steps: int = 1, level: str = "WARN", color: bool = False) -> None:
101
+ self.out = out
102
+ self.steps = steps
103
+ self.level = LEVELS[level]
104
+ self.color = color
105
+ self.state = RunState()
106
+
107
+ def show(self, events: Iterable[Event]) -> None:
108
+ for event in events:
109
+ self.state.apply(event)
110
+ self.print_event(event)
111
+
112
+ def print_event(self, event: Event) -> None:
113
+ if isinstance(event, SuiteStarted):
114
+ self.line(self.indent(event.id), event.longname if event.parent is None else event.name)
115
+ elif isinstance(event, TestStarted):
116
+ self.line(self.indent(event.id), event.name)
117
+ elif isinstance(event, StepStarted):
118
+ depth = self.step_depth(event.id)
119
+ if depth <= self.steps:
120
+ node = self.state.nodes[event.id]
121
+ kind = "" if event.kind == "KEYWORD" else ("ITER" if event.kind == "ITERATION" else event.kind)
122
+ parts = [kind, step_label(node), *(event.args or [])]
123
+ self.line(self.indent(event.id), " ".join(p for p in parts if p))
124
+ elif isinstance(event, TestEnded):
125
+ node = self.state.nodes[event.id]
126
+ self.line(
127
+ self.indent(event.id), self.paint(node.status, node.status), node.name, f"({event.elapsed:.2f} s)"
128
+ )
129
+ if node.message:
130
+ self.line(self.indent(event.id) + " ", node.message)
131
+ elif isinstance(event, SuiteEnded):
132
+ node = self.state.nodes[event.id]
133
+ if node.parent is not None:
134
+ return
135
+ s = event.stats
136
+ self.line(
137
+ "",
138
+ self.paint(node.status, node.status),
139
+ getattr(node.started, "longname", node.name),
140
+ f"{s['total']} tests, {s['passed']} passed, {s['failed']} failed, {s['skipped']} skipped",
141
+ )
142
+ elif isinstance(event, MessageLogged):
143
+ if LEVELS.get(event.level, 2) >= self.level and event.parent in self.state.nodes:
144
+ self.line(
145
+ self.indent(self.shown(event.parent)) + " ",
146
+ self.paint(event.level, f"[{event.level}]"),
147
+ event.text or "",
148
+ )
149
+ elif isinstance(event, RunEnded):
150
+ self.out.flush()
151
+
152
+ def indent(self, node_id: str | None) -> str:
153
+ return " " * (len(self.state.path(node_id or "")) - 1)
154
+
155
+ def shown(self, node_id: str | None) -> str | None:
156
+ """The node, or its nearest ancestor that is printed."""
157
+ for node in reversed(self.state.path(node_id or "")):
158
+ if node.kind in ("SUITE", "TEST") or self.step_depth(node.id) <= self.steps:
159
+ return node.id
160
+ return node_id
161
+
162
+ def step_depth(self, node_id: str) -> int:
163
+ """1 for steps directly in a test or suite, 2 for their children, ..."""
164
+ return sum(1 for node in self.state.path(node_id) if node.kind not in ("SUITE", "TEST"))
165
+
166
+ def paint(self, status: str, text: str) -> str:
167
+ code = COLORS.get(status)
168
+ return f"\033[{code}m{text}\033[0m" if self.color and code else text
169
+
170
+ def line(self, indent: str, *parts: str) -> None:
171
+ self.out.write(indent + " ".join(p for p in parts if p) + "\n")
172
+ self.out.flush()
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
@@ -0,0 +1,172 @@
1
+ """Events built from an existing ``output.xml`` or ``output.json``.
2
+
3
+ Gives events for runs that were recorded without the listener, so they can be replayed.
4
+ What the result file does not store is left out: steps have no ``source`` and ``lineno``,
5
+ ``run.started`` has no ``pid`` and ``cwd``, and children of ``robot:flatten`` keywords are
6
+ missing, as in ``log.html``. Statuses are final, so there are no ``status.changed`` events.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import uuid
14
+ from collections.abc import Iterator
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from robot.api import ExecutionResult
19
+ from robot.result import TestCase, TestSuite
20
+
21
+ from .events import FORMAT_VERSION
22
+ from .listener import ROOT_TYPES, _stats, step_fields
23
+
24
+ GENERATOR = re.compile(r"Robot (\S+) \(\w+ (\S+)")
25
+
26
+
27
+ def convert(path: str | os.PathLike[str], *, run: str | None = None, max_text: int = 4096) -> Iterator[dict[str, Any]]:
28
+ """Yields the events of a result file as dicts, in the order a live run emits them."""
29
+ result = ExecutionResult(str(path))
30
+ return _Converter(max_text).events(result, str(path), run or str(uuid.uuid4()))
31
+
32
+
33
+ def _time(value: datetime | None) -> float | None:
34
+ return round(value.timestamp(), 3) if value else None
35
+
36
+
37
+ class _Counter:
38
+ __slots__ = ("children", "id")
39
+
40
+ def __init__(self, id: str) -> None:
41
+ self.id = id
42
+ self.children = 0
43
+
44
+
45
+ class _Converter:
46
+ def __init__(self, max_text: int) -> None:
47
+ self.max_text = max_text
48
+ self.seq = 0
49
+ self.t = 0.0
50
+
51
+ def emit(self, type_: str, t: float | None, **fields: Any) -> dict[str, Any]:
52
+ self.seq += 1
53
+ # NOT RUN items have no times; keep time moving forward
54
+ self.t = max(self.t, t) if t is not None else self.t
55
+ event: dict[str, Any] = {"v": FORMAT_VERSION, "seq": self.seq, "t": self.t, "type": type_}
56
+ event.update((k, v) for k, v in fields.items() if v is not None and v != "")
57
+ return event
58
+
59
+ def events(self, result: Any, path: str, run: str) -> Iterator[dict[str, Any]]:
60
+ suite = result.suite
61
+ match = GENERATOR.match(getattr(result, "generator", "") or "")
62
+ self.t = _time(suite.start_time) or 0.0
63
+ yield self.emit(
64
+ "run.started",
65
+ self.t,
66
+ run=run,
67
+ rf=match.group(1) if match else "unknown",
68
+ python=match.group(2) if match else None,
69
+ )
70
+ yield from self.suite(suite, "s1", None)
71
+ yield self.emit("resultfile.created", None, kind="OUTPUT", path=path)
72
+ yield self.emit("run.ended", None, status=suite.status, stats=_stats(suite))
73
+
74
+ def suite(self, suite: TestSuite, id: str, parent: str | None) -> Iterator[dict[str, Any]]:
75
+ yield self.emit(
76
+ "suite.started",
77
+ _time(suite.start_time),
78
+ id=id,
79
+ parent=parent,
80
+ name=suite.name,
81
+ longname=suite.full_name,
82
+ source=str(suite.source) if suite.source else None,
83
+ tests=suite.test_count,
84
+ )
85
+ counter = _Counter(id)
86
+ if suite.has_setup:
87
+ yield from self.step(suite.setup, counter, id)
88
+ for index, child in enumerate(suite.suites, 1):
89
+ yield from self.suite(child, f"{id}-s{index}", id)
90
+ for index, test in enumerate(suite.tests, 1):
91
+ yield from self.test(test, f"{id}-t{index}", id)
92
+ if suite.has_teardown:
93
+ yield from self.step(suite.teardown, counter, id)
94
+ yield self.emit(
95
+ "suite.ended",
96
+ _time(suite.end_time),
97
+ id=id,
98
+ status=suite.status,
99
+ message=suite.message,
100
+ elapsed=round(suite.elapsed_time.total_seconds(), 3),
101
+ stats=_stats(suite),
102
+ )
103
+
104
+ def test(self, test: TestCase, id: str, parent: str) -> Iterator[dict[str, Any]]:
105
+ yield self.emit(
106
+ "test.started",
107
+ _time(test.start_time),
108
+ id=id,
109
+ parent=parent,
110
+ name=test.name,
111
+ longname=test.full_name,
112
+ source=str(test.source) if test.source else None,
113
+ lineno=test.lineno,
114
+ tags=list(test.tags) or None,
115
+ )
116
+ counter = _Counter(id)
117
+ if test.has_setup:
118
+ yield from self.step(test.setup, counter, id)
119
+ yield from self.body(test.body, counter, id)
120
+ if test.has_teardown:
121
+ yield from self.step(test.teardown, counter, id)
122
+ yield self.emit(
123
+ "test.ended",
124
+ _time(test.end_time),
125
+ id=id,
126
+ status=test.status,
127
+ message=test.message,
128
+ elapsed=round(test.elapsed_time.total_seconds(), 3),
129
+ )
130
+
131
+ def body(self, items: Any, counter: _Counter, parent: str) -> Iterator[dict[str, Any]]:
132
+ for item in items:
133
+ if item.type == "MESSAGE":
134
+ text = item.message or ""
135
+ truncated = len(text) > self.max_text
136
+ yield self.emit(
137
+ "message.logged",
138
+ _time(item.timestamp),
139
+ parent=parent,
140
+ level=item.level,
141
+ text=text[: self.max_text] if truncated else text,
142
+ truncated=True if truncated else None,
143
+ html=True if item.html else None,
144
+ )
145
+ elif item.type in ROOT_TYPES:
146
+ # IF/TRY branches are numbered as children of the root's parent
147
+ yield from self.body(item.body, counter, parent)
148
+ else:
149
+ yield from self.step(item, counter, parent)
150
+
151
+ def step(self, item: Any, counter: _Counter, parent: str) -> Iterator[dict[str, Any]]:
152
+ counter.children += 1
153
+ id = f"{counter.id}-k{counter.children}"
154
+ kind = item.type
155
+ yield self.emit(
156
+ "step.started", _time(item.start_time), id=id, parent=parent, kind=kind, **step_fields(kind, item)
157
+ )
158
+ own = _Counter(id)
159
+ if getattr(item, "has_setup", False):
160
+ yield from self.step(item.setup, own, id)
161
+ if hasattr(item, "body"):
162
+ yield from self.body(item.body, own, id)
163
+ if getattr(item, "has_teardown", False):
164
+ yield from self.step(item.teardown, own, id)
165
+ yield self.emit(
166
+ "step.ended",
167
+ _time(item.end_time),
168
+ id=id,
169
+ status=item.status,
170
+ message=item.message,
171
+ elapsed=round(item.elapsed_time.total_seconds(), 4),
172
+ )
@@ -0,0 +1,175 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/timdegroot1996/robotframework-events/blob/main/src/robotframework_events/events-v1.schema.json",
4
+ "title": "Robot Framework execution event, format v1",
5
+ "description": "One line of an events JSONL file. Fields with no value are left out. Readers ignore unknown fields and unknown event types.",
6
+ "type": "object",
7
+ "required": ["v", "seq", "t", "type"],
8
+ "properties": {
9
+ "v": { "description": "Format version.", "const": 1 },
10
+ "seq": { "description": "1, 2, 3, ... per stream, without gaps.", "type": "integer", "minimum": 1 },
11
+ "t": { "description": "Unix time in seconds.", "type": "number" },
12
+ "type": { "type": "string" }
13
+ },
14
+ "allOf": [
15
+ { "if": { "properties": { "type": { "const": "run.started" } } }, "then": { "$ref": "#/$defs/run.started" } },
16
+ { "if": { "properties": { "type": { "const": "run.ended" } } }, "then": { "$ref": "#/$defs/run.ended" } },
17
+ { "if": { "properties": { "type": { "const": "suite.started" } } }, "then": { "$ref": "#/$defs/suite.started" } },
18
+ { "if": { "properties": { "type": { "const": "suite.ended" } } }, "then": { "$ref": "#/$defs/suite.ended" } },
19
+ { "if": { "properties": { "type": { "const": "test.started" } } }, "then": { "$ref": "#/$defs/test.started" } },
20
+ { "if": { "properties": { "type": { "const": "test.ended" } } }, "then": { "$ref": "#/$defs/test.ended" } },
21
+ { "if": { "properties": { "type": { "const": "step.started" } } }, "then": { "$ref": "#/$defs/step.started" } },
22
+ { "if": { "properties": { "type": { "const": "step.ended" } } }, "then": { "$ref": "#/$defs/step.ended" } },
23
+ { "if": { "properties": { "type": { "const": "message.logged" } } }, "then": { "$ref": "#/$defs/message.logged" } },
24
+ { "if": { "properties": { "type": { "const": "status.changed" } } }, "then": { "$ref": "#/$defs/status.changed" } },
25
+ { "if": { "properties": { "type": { "const": "resultfile.created" } } }, "then": { "$ref": "#/$defs/resultfile.created" } }
26
+ ],
27
+ "$defs": {
28
+ "id": {
29
+ "description": "Robot Framework's item id, as in output.xml and log.html#<id>.",
30
+ "type": "string",
31
+ "pattern": "^s\\d+(-s\\d+)*(-t\\d+)?(-k\\d+)*$"
32
+ },
33
+ "status": { "enum": ["PASS", "FAIL", "SKIP", "NOT RUN", "NOT SET"] },
34
+ "stats": {
35
+ "type": "object",
36
+ "required": ["total", "passed", "failed", "skipped"],
37
+ "properties": {
38
+ "total": { "type": "integer", "minimum": 0 },
39
+ "passed": { "type": "integer", "minimum": 0 },
40
+ "failed": { "type": "integer", "minimum": 0 },
41
+ "skipped": { "type": "integer", "minimum": 0 }
42
+ }
43
+ },
44
+ "strings": { "type": "array", "items": { "type": "string" } },
45
+
46
+ "run.started": {
47
+ "description": "First event of a stream. 'run' identifies the run; it is not repeated in later events. 'python', 'pid' and 'cwd' are missing in events converted from a result file.",
48
+ "required": ["run", "rf"],
49
+ "properties": {
50
+ "run": { "type": "string", "format": "uuid" },
51
+ "rf": { "description": "Robot Framework version.", "type": "string" },
52
+ "python": { "description": "Python version.", "type": "string" },
53
+ "pid": { "type": "integer" },
54
+ "cwd": { "type": "string" }
55
+ }
56
+ },
57
+ "run.ended": {
58
+ "description": "Last event of a stream.",
59
+ "required": ["status", "stats"],
60
+ "properties": {
61
+ "status": { "$ref": "#/$defs/status" },
62
+ "stats": { "$ref": "#/$defs/stats" }
63
+ }
64
+ },
65
+ "suite.started": {
66
+ "required": ["id", "name", "longname", "tests"],
67
+ "properties": {
68
+ "id": { "$ref": "#/$defs/id" },
69
+ "parent": { "description": "Left out for the top-level suite.", "$ref": "#/$defs/id" },
70
+ "name": { "type": "string" },
71
+ "longname": { "type": "string" },
72
+ "source": { "type": "string" },
73
+ "tests": { "description": "Number of tests in the suite and its child suites.", "type": "integer", "minimum": 0 }
74
+ }
75
+ },
76
+ "suite.ended": {
77
+ "required": ["id", "status", "elapsed", "stats"],
78
+ "properties": {
79
+ "id": { "$ref": "#/$defs/id" },
80
+ "status": { "$ref": "#/$defs/status" },
81
+ "message": { "type": "string" },
82
+ "elapsed": { "description": "Seconds.", "type": "number", "minimum": 0 },
83
+ "stats": { "$ref": "#/$defs/stats" }
84
+ }
85
+ },
86
+ "test.started": {
87
+ "required": ["id", "parent", "name", "longname"],
88
+ "properties": {
89
+ "id": { "$ref": "#/$defs/id" },
90
+ "parent": { "$ref": "#/$defs/id" },
91
+ "name": { "type": "string" },
92
+ "longname": { "type": "string" },
93
+ "source": { "type": "string" },
94
+ "lineno": { "type": "integer" },
95
+ "tags": { "$ref": "#/$defs/strings" }
96
+ }
97
+ },
98
+ "test.ended": {
99
+ "required": ["id", "status", "elapsed"],
100
+ "properties": {
101
+ "id": { "$ref": "#/$defs/id" },
102
+ "status": { "$ref": "#/$defs/status" },
103
+ "message": { "type": "string" },
104
+ "elapsed": { "description": "Seconds.", "type": "number", "minimum": 0 }
105
+ }
106
+ },
107
+ "step.started": {
108
+ "description": "A keyword, setup, teardown or control structure. IF/ELSE and TRY/EXCEPT roots are not emitted: their branches are children of the root's parent.",
109
+ "required": ["id", "kind"],
110
+ "properties": {
111
+ "id": { "$ref": "#/$defs/id" },
112
+ "parent": { "description": "Nearest emitted ancestor.", "$ref": "#/$defs/id" },
113
+ "kind": {
114
+ "enum": ["KEYWORD", "SETUP", "TEARDOWN", "FOR", "ITERATION", "IF", "ELSE IF", "ELSE", "TRY", "EXCEPT", "FINALLY", "WHILE", "GROUP", "VAR", "RETURN", "BREAK", "CONTINUE", "ERROR"]
115
+ },
116
+ "source": { "description": "Left out when equal to the parent's source.", "type": "string" },
117
+ "lineno": { "type": "integer" },
118
+ "name": { "description": "Keyword, GROUP or VAR name.", "type": "string" },
119
+ "owner": { "description": "Library or resource file of a keyword.", "type": "string" },
120
+ "args": { "description": "Keyword arguments as written, truncated.", "$ref": "#/$defs/strings" },
121
+ "assign": {
122
+ "description": "Variables of a keyword or FOR (list), values of an ITERATION ({variable: value}), variable of an EXCEPT (string).",
123
+ "anyOf": [
124
+ { "$ref": "#/$defs/strings" },
125
+ { "type": "object", "additionalProperties": { "type": "string" } },
126
+ { "type": "string" }
127
+ ]
128
+ },
129
+ "flatten": { "description": "Keyword tagged robot:flatten: its children are emitted, but are not in output.xml.", "const": true },
130
+ "flavor": { "description": "FOR flavor, for example 'IN RANGE'.", "type": "string" },
131
+ "values": { "description": "FOR values, VAR values, RETURN values or ERROR values.", "$ref": "#/$defs/strings" },
132
+ "condition": { "description": "IF, ELSE IF or WHILE condition.", "type": "string" },
133
+ "limit": { "description": "WHILE limit.", "type": "string" },
134
+ "patterns": { "description": "EXCEPT patterns.", "$ref": "#/$defs/strings" },
135
+ "scope": { "description": "VAR scope.", "type": "string" }
136
+ }
137
+ },
138
+ "step.ended": {
139
+ "required": ["id", "status", "elapsed"],
140
+ "properties": {
141
+ "id": { "$ref": "#/$defs/id" },
142
+ "status": { "$ref": "#/$defs/status" },
143
+ "message": { "type": "string" },
144
+ "elapsed": { "description": "Seconds.", "type": "number", "minimum": 0 }
145
+ }
146
+ },
147
+ "message.logged": {
148
+ "required": ["level"],
149
+ "properties": {
150
+ "parent": { "description": "The suite, test or step that logged the message.", "$ref": "#/$defs/id" },
151
+ "level": { "enum": ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FAIL", "SKIP"] },
152
+ "text": { "description": "Left out when empty; cut to max_text characters.", "type": "string" },
153
+ "truncated": { "const": true },
154
+ "html": { "const": true }
155
+ }
156
+ },
157
+ "status.changed": {
158
+ "description": "A failing or skipping suite teardown changed the status of a suite or test that already ended. Readers apply the new status.",
159
+ "required": ["id", "item", "status"],
160
+ "properties": {
161
+ "id": { "$ref": "#/$defs/id" },
162
+ "item": { "enum": ["suite", "test"] },
163
+ "status": { "$ref": "#/$defs/status" },
164
+ "message": { "type": "string" }
165
+ }
166
+ },
167
+ "resultfile.created": {
168
+ "required": ["kind", "path"],
169
+ "properties": {
170
+ "kind": { "enum": ["OUTPUT", "LOG", "REPORT", "XUNIT", "DEBUG"] },
171
+ "path": { "type": "string" }
172
+ }
173
+ }
174
+ }
175
+ }