@descryy/runtime-test-runner 0.4.11 → 0.4.13

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.
Files changed (278) hide show
  1. package/dist/bounded-file.d.ts +18 -0
  2. package/dist/bounded-file.d.ts.map +1 -0
  3. package/dist/bounded-file.js +77 -0
  4. package/dist/bounded-file.js.map +1 -0
  5. package/dist/child-environment.d.ts +12 -0
  6. package/dist/child-environment.d.ts.map +1 -0
  7. package/dist/child-environment.js +124 -0
  8. package/dist/child-environment.js.map +1 -0
  9. package/dist/coverage-capture.d.ts +23 -0
  10. package/dist/coverage-capture.d.ts.map +1 -0
  11. package/dist/coverage-capture.js +165 -0
  12. package/dist/coverage-capture.js.map +1 -0
  13. package/dist/executed-coverage.d.ts +45 -0
  14. package/dist/executed-coverage.d.ts.map +1 -0
  15. package/dist/executed-coverage.js +143 -0
  16. package/dist/executed-coverage.js.map +1 -0
  17. package/dist/go-cover-profile.d.ts +29 -0
  18. package/dist/go-cover-profile.d.ts.map +1 -0
  19. package/dist/go-cover-profile.js +162 -0
  20. package/dist/go-cover-profile.js.map +1 -0
  21. package/dist/go-test.d.ts +29 -2
  22. package/dist/go-test.d.ts.map +1 -1
  23. package/dist/go-test.js +149 -30
  24. package/dist/go-test.js.map +1 -1
  25. package/dist/graph-mapping.d.ts +1 -0
  26. package/dist/graph-mapping.d.ts.map +1 -1
  27. package/dist/graph-mapping.js +78 -1
  28. package/dist/graph-mapping.js.map +1 -1
  29. package/dist/index.d.ts +21 -3
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +11 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/lexers/c-family.d.ts +4 -0
  34. package/dist/lexers/c-family.d.ts.map +1 -0
  35. package/dist/lexers/c-family.js +221 -0
  36. package/dist/lexers/c-family.js.map +1 -0
  37. package/dist/lexers/chars.d.ts +7 -0
  38. package/dist/lexers/chars.d.ts.map +1 -0
  39. package/dist/lexers/chars.js +49 -0
  40. package/dist/lexers/chars.js.map +1 -0
  41. package/dist/lexers/index.d.ts +7 -0
  42. package/dist/lexers/index.d.ts.map +1 -0
  43. package/dist/lexers/index.js +5 -0
  44. package/dist/lexers/index.js.map +1 -0
  45. package/dist/lexers/python.d.ts +3 -0
  46. package/dist/lexers/python.d.ts.map +1 -0
  47. package/dist/lexers/python.js +101 -0
  48. package/dist/lexers/python.js.map +1 -0
  49. package/dist/quality/annotations.d.ts +14 -0
  50. package/dist/quality/annotations.d.ts.map +1 -0
  51. package/dist/quality/annotations.js +147 -0
  52. package/dist/quality/annotations.js.map +1 -0
  53. package/dist/quality/assertion-kinds.d.ts +18 -0
  54. package/dist/quality/assertion-kinds.d.ts.map +1 -0
  55. package/dist/quality/assertion-kinds.js +25 -0
  56. package/dist/quality/assertion-kinds.js.map +1 -0
  57. package/dist/quality/chains.d.ts +25 -0
  58. package/dist/quality/chains.d.ts.map +1 -0
  59. package/dist/quality/chains.js +129 -0
  60. package/dist/quality/chains.js.map +1 -0
  61. package/dist/quality/common.d.ts +31 -0
  62. package/dist/quality/common.d.ts.map +1 -0
  63. package/dist/quality/common.js +211 -0
  64. package/dist/quality/common.js.map +1 -0
  65. package/dist/quality/csharp-family.d.ts +9 -0
  66. package/dist/quality/csharp-family.d.ts.map +1 -0
  67. package/dist/quality/csharp-family.js +560 -0
  68. package/dist/quality/csharp-family.js.map +1 -0
  69. package/dist/quality/detect.d.ts +3 -0
  70. package/dist/quality/detect.d.ts.map +1 -0
  71. package/dist/quality/detect.js +348 -0
  72. package/dist/quality/detect.js.map +1 -0
  73. package/dist/quality/durations.d.ts +4 -0
  74. package/dist/quality/durations.d.ts.map +1 -0
  75. package/dist/quality/durations.js +177 -0
  76. package/dist/quality/durations.js.map +1 -0
  77. package/dist/quality/expressions.d.ts +7 -0
  78. package/dist/quality/expressions.d.ts.map +1 -0
  79. package/dist/quality/expressions.js +123 -0
  80. package/dist/quality/expressions.js.map +1 -0
  81. package/dist/quality/facts.d.ts +5 -0
  82. package/dist/quality/facts.d.ts.map +1 -0
  83. package/dist/quality/facts.js +183 -0
  84. package/dist/quality/facts.js.map +1 -0
  85. package/dist/quality/failure-of.d.ts +4 -0
  86. package/dist/quality/failure-of.d.ts.map +1 -0
  87. package/dist/quality/failure-of.js +233 -0
  88. package/dist/quality/failure-of.js.map +1 -0
  89. package/dist/quality/frameworks.d.ts +5 -0
  90. package/dist/quality/frameworks.d.ts.map +1 -0
  91. package/dist/quality/frameworks.js +20 -0
  92. package/dist/quality/frameworks.js.map +1 -0
  93. package/dist/quality/go-family.d.ts +6 -0
  94. package/dist/quality/go-family.d.ts.map +1 -0
  95. package/dist/quality/go-family.js +393 -0
  96. package/dist/quality/go-family.js.map +1 -0
  97. package/dist/quality/index.d.ts +10 -0
  98. package/dist/quality/index.d.ts.map +1 -0
  99. package/dist/quality/index.js +19 -0
  100. package/dist/quality/index.js.map +1 -0
  101. package/dist/quality/java-family.d.ts +12 -0
  102. package/dist/quality/java-family.d.ts.map +1 -0
  103. package/dist/quality/java-family.js +529 -0
  104. package/dist/quality/java-family.js.map +1 -0
  105. package/dist/quality/js-assertions.d.ts +11 -0
  106. package/dist/quality/js-assertions.d.ts.map +1 -0
  107. package/dist/quality/js-assertions.js +297 -0
  108. package/dist/quality/js-assertions.js.map +1 -0
  109. package/dist/quality/js-family.d.ts +3 -0
  110. package/dist/quality/js-family.d.ts.map +1 -0
  111. package/dist/quality/js-family.js +470 -0
  112. package/dist/quality/js-family.js.map +1 -0
  113. package/dist/quality/python-family.d.ts +6 -0
  114. package/dist/quality/python-family.d.ts.map +1 -0
  115. package/dist/quality/python-family.js +786 -0
  116. package/dist/quality/python-family.js.map +1 -0
  117. package/dist/quality/scope.d.ts +26 -0
  118. package/dist/quality/scope.d.ts.map +1 -0
  119. package/dist/quality/scope.js +33 -0
  120. package/dist/quality/scope.js.map +1 -0
  121. package/dist/quality/selectors.d.ts +14 -0
  122. package/dist/quality/selectors.d.ts.map +1 -0
  123. package/dist/quality/selectors.js +137 -0
  124. package/dist/quality/selectors.js.map +1 -0
  125. package/dist/quality/statuses.d.ts +7 -0
  126. package/dist/quality/statuses.d.ts.map +1 -0
  127. package/dist/quality/statuses.js +143 -0
  128. package/dist/quality/statuses.js.map +1 -0
  129. package/dist/quality/stream.d.ts +23 -0
  130. package/dist/quality/stream.d.ts.map +1 -0
  131. package/dist/quality/stream.js +194 -0
  132. package/dist/quality/stream.js.map +1 -0
  133. package/dist/quality/types.d.ts +88 -0
  134. package/dist/quality/types.d.ts.map +1 -0
  135. package/dist/quality/types.js +2 -0
  136. package/dist/quality/types.js.map +1 -0
  137. package/dist/run-timeout.d.ts +25 -0
  138. package/dist/run-timeout.d.ts.map +1 -0
  139. package/dist/run-timeout.js +52 -0
  140. package/dist/run-timeout.js.map +1 -0
  141. package/dist/runner-wrappers.d.ts +74 -0
  142. package/dist/runner-wrappers.d.ts.map +1 -0
  143. package/dist/runner-wrappers.js +604 -0
  144. package/dist/runner-wrappers.js.map +1 -0
  145. package/dist/swift-xctest.d.ts +15 -2
  146. package/dist/swift-xctest.d.ts.map +1 -1
  147. package/dist/swift-xctest.js +143 -20
  148. package/dist/swift-xctest.js.map +1 -1
  149. package/dist/test-runner-collector.d.ts +56 -6
  150. package/dist/test-runner-collector.d.ts.map +1 -1
  151. package/dist/test-runner-collector.js +265 -113
  152. package/dist/test-runner-collector.js.map +1 -1
  153. package/dist/testgen/csharp-facts.d.ts +3 -0
  154. package/dist/testgen/csharp-facts.d.ts.map +1 -0
  155. package/dist/testgen/csharp-facts.js +306 -0
  156. package/dist/testgen/csharp-facts.js.map +1 -0
  157. package/dist/testgen/csharp-profile.d.ts +14 -0
  158. package/dist/testgen/csharp-profile.d.ts.map +1 -0
  159. package/dist/testgen/csharp-profile.js +233 -0
  160. package/dist/testgen/csharp-profile.js.map +1 -0
  161. package/dist/testgen/declarations.d.ts +10 -0
  162. package/dist/testgen/declarations.d.ts.map +1 -0
  163. package/dist/testgen/declarations.js +110 -0
  164. package/dist/testgen/declarations.js.map +1 -0
  165. package/dist/testgen/facts-common.d.ts +33 -0
  166. package/dist/testgen/facts-common.d.ts.map +1 -0
  167. package/dist/testgen/facts-common.js +76 -0
  168. package/dist/testgen/facts-common.js.map +1 -0
  169. package/dist/testgen/failure.d.ts +4 -0
  170. package/dist/testgen/failure.d.ts.map +1 -0
  171. package/dist/testgen/failure.js +106 -0
  172. package/dist/testgen/failure.js.map +1 -0
  173. package/dist/testgen/frameworks.d.ts +6 -0
  174. package/dist/testgen/frameworks.d.ts.map +1 -0
  175. package/dist/testgen/frameworks.js +516 -0
  176. package/dist/testgen/frameworks.js.map +1 -0
  177. package/dist/testgen/go-facts.d.ts +11 -0
  178. package/dist/testgen/go-facts.d.ts.map +1 -0
  179. package/dist/testgen/go-facts.js +237 -0
  180. package/dist/testgen/go-facts.js.map +1 -0
  181. package/dist/testgen/go-profile.d.ts +6 -0
  182. package/dist/testgen/go-profile.d.ts.map +1 -0
  183. package/dist/testgen/go-profile.js +155 -0
  184. package/dist/testgen/go-profile.js.map +1 -0
  185. package/dist/testgen/index.d.ts +28 -0
  186. package/dist/testgen/index.d.ts.map +1 -0
  187. package/dist/testgen/index.js +21 -0
  188. package/dist/testgen/index.js.map +1 -0
  189. package/dist/testgen/java-build.d.ts +13 -0
  190. package/dist/testgen/java-build.d.ts.map +1 -0
  191. package/dist/testgen/java-build.js +250 -0
  192. package/dist/testgen/java-build.js.map +1 -0
  193. package/dist/testgen/java-facts.d.ts +3 -0
  194. package/dist/testgen/java-facts.d.ts.map +1 -0
  195. package/dist/testgen/java-facts.js +349 -0
  196. package/dist/testgen/java-facts.js.map +1 -0
  197. package/dist/testgen/java-profile.d.ts +13 -0
  198. package/dist/testgen/java-profile.d.ts.map +1 -0
  199. package/dist/testgen/java-profile.js +194 -0
  200. package/dist/testgen/java-profile.js.map +1 -0
  201. package/dist/testgen/languages.d.ts +11 -0
  202. package/dist/testgen/languages.d.ts.map +1 -0
  203. package/dist/testgen/languages.js +63 -0
  204. package/dist/testgen/languages.js.map +1 -0
  205. package/dist/testgen/lexer.d.ts +19 -0
  206. package/dist/testgen/lexer.d.ts.map +1 -0
  207. package/dist/testgen/lexer.js +257 -0
  208. package/dist/testgen/lexer.js.map +1 -0
  209. package/dist/testgen/mutant-core.d.ts +19 -0
  210. package/dist/testgen/mutant-core.d.ts.map +1 -0
  211. package/dist/testgen/mutant-core.js +39 -0
  212. package/dist/testgen/mutant-core.js.map +1 -0
  213. package/dist/testgen/mutants-lang.d.ts +6 -0
  214. package/dist/testgen/mutants-lang.d.ts.map +1 -0
  215. package/dist/testgen/mutants-lang.js +291 -0
  216. package/dist/testgen/mutants-lang.js.map +1 -0
  217. package/dist/testgen/mutants.d.ts +9 -0
  218. package/dist/testgen/mutants.d.ts.map +1 -0
  219. package/dist/testgen/mutants.js +148 -0
  220. package/dist/testgen/mutants.js.map +1 -0
  221. package/dist/testgen/paths.d.ts +32 -0
  222. package/dist/testgen/paths.d.ts.map +1 -0
  223. package/dist/testgen/paths.js +211 -0
  224. package/dist/testgen/paths.js.map +1 -0
  225. package/dist/testgen/process.d.ts +19 -0
  226. package/dist/testgen/process.d.ts.map +1 -0
  227. package/dist/testgen/process.js +89 -0
  228. package/dist/testgen/process.js.map +1 -0
  229. package/dist/testgen/profile-failure.d.ts +7 -0
  230. package/dist/testgen/profile-failure.d.ts.map +1 -0
  231. package/dist/testgen/profile-failure.js +29 -0
  232. package/dist/testgen/profile-failure.js.map +1 -0
  233. package/dist/testgen/python-facts.d.ts +11 -0
  234. package/dist/testgen/python-facts.d.ts.map +1 -0
  235. package/dist/testgen/python-facts.js +380 -0
  236. package/dist/testgen/python-facts.js.map +1 -0
  237. package/dist/testgen/python-profile.d.ts +4 -0
  238. package/dist/testgen/python-profile.d.ts.map +1 -0
  239. package/dist/testgen/python-profile.js +344 -0
  240. package/dist/testgen/python-profile.js.map +1 -0
  241. package/dist/testgen/source-facts.d.ts +30 -0
  242. package/dist/testgen/source-facts.d.ts.map +1 -0
  243. package/dist/testgen/source-facts.js +497 -0
  244. package/dist/testgen/source-facts.js.map +1 -0
  245. package/dist/testgen/stubs.d.ts +4 -0
  246. package/dist/testgen/stubs.d.ts.map +1 -0
  247. package/dist/testgen/stubs.js +66 -0
  248. package/dist/testgen/stubs.js.map +1 -0
  249. package/dist/testgen/toolchain.d.ts +23 -0
  250. package/dist/testgen/toolchain.d.ts.map +1 -0
  251. package/dist/testgen/toolchain.js +201 -0
  252. package/dist/testgen/toolchain.js.map +1 -0
  253. package/dist/testgen/types.d.ts +67 -0
  254. package/dist/testgen/types.d.ts.map +1 -0
  255. package/dist/testgen/types.js +2 -0
  256. package/dist/testgen/types.js.map +1 -0
  257. package/dist/v8-coverage.d.ts +21 -0
  258. package/dist/v8-coverage.d.ts.map +1 -0
  259. package/dist/v8-coverage.js +217 -0
  260. package/dist/v8-coverage.js.map +1 -0
  261. package/dist/vitest-reporter.d.ts +46 -13
  262. package/dist/vitest-reporter.d.ts.map +1 -1
  263. package/dist/vitest-reporter.js +119 -26
  264. package/dist/vitest-reporter.js.map +1 -1
  265. package/dist/vitest-version.d.ts +21 -0
  266. package/dist/vitest-version.d.ts.map +1 -0
  267. package/dist/vitest-version.js +98 -0
  268. package/dist/vitest-version.js.map +1 -0
  269. package/package.json +14 -8
  270. package/reporters/junit/classes/DescryJUnitListener.class +0 -0
  271. package/reporters/junit/classes/META-INF/services/org.junit.platform.launcher.TestExecutionListener +1 -0
  272. package/reporters/minitest/descry_minitest_reporter.rb +150 -0
  273. package/reporters/phpunit/DescryPhpUnitExtension.php +209 -0
  274. package/reporters/pytest/descry_pytest_plugin.py +397 -0
  275. package/reporters/rspec/descry_rspec_formatter.rb +175 -0
  276. package/reporters/xunit/bin/Release/netstandard2.0/Descry.TestLogger.deps.json +203 -0
  277. package/reporters/xunit/bin/Release/netstandard2.0/Descry.TestLogger.dll +0 -0
  278. package/reporters/xunit/bin/Release/netstandard2.0/Descry.TestLogger.pdb +0 -0
@@ -0,0 +1,209 @@
1
+ <?php declare(strict_types=1);
2
+
3
+ /*
4
+ * §17's PHPUnit reporter — a runner extension writing the same NDJSON
5
+ * envelope every other reporter in this package writes (RT-109).
6
+ *
7
+ * ## Why an extension and not the JUnit XML or TestDox output
8
+ *
9
+ * PHPUnit emits JUnit XML, and RT-088 measured what that format costs:
10
+ * `@Disabled` and an aborted assumption collapse into an identical bare
11
+ * `<skipped>`. PHPUnit has the same problem with more to lose —
12
+ * `markTestSkipped()` and `markTestIncomplete()` are different facts about
13
+ * coverage and the XML writes both as `<skipped>`.
14
+ *
15
+ * PHPUnit's **event system** is its own structured stream. It also removes
16
+ * the shape RT-088 warned about generally: a document written at the end of
17
+ * the run produces nothing at all if the run is killed.
18
+ *
19
+ * `TestListener` is not an option here and should not be looked for: it was
20
+ * removed in PHPUnit 10 and replaced by this.
21
+ *
22
+ * ## The four outcomes, and the one that needs a snapshot
23
+ *
24
+ * `MarkedIncomplete` -> `not-implemented`. PHPUnit's own word for it is
25
+ * "incomplete", meaning the test is a placeholder for behaviour that is not
26
+ * written. That is `test.todo` exactly.
27
+ *
28
+ * `Errored` and `Failed` are both `test:fail`, named apart in the payload:
29
+ * an assertion that did not hold and an exception that escaped are different
30
+ * things to go and look at, and PHPUnit is the only layer that knows which
31
+ * happened.
32
+ *
33
+ * `Skipped` is the one that needs care, because PHPUnit reaches it two ways
34
+ * — a `#[Requires*]` attribute the runner evaluated *before* the test, and
35
+ * `markTestSkipped()` *inside* the body. Measured: `Test\Prepared` fires only
36
+ * for the second. So a `Skipped` with a preceding `Prepared` ran real code
37
+ * and bailed on an assumption (`precondition-unmet`, JUnit's `ABORTED`), and
38
+ * one without never started (`disabled`).
39
+ *
40
+ * That is the same shape RSpec needed (its metadata is mutated by execution)
41
+ * and pytest needed (its markers are only readable at collection). Three
42
+ * runners, three different mechanisms, one rule: **the distinction is read
43
+ * from the runner's own event order, never parsed out of a message string.**
44
+ *
45
+ * ## The envelope version
46
+ *
47
+ * Declared before the first event, as a number: `{"version":"1"}` is read as
48
+ * a broken reporter rather than a matching one. Keep in step with
49
+ * `ENVELOPE_VERSION` in `test-runner-collector.ts` — that file is the
50
+ * authority, not this comment.
51
+ */
52
+
53
+ use PHPUnit\Event\Test\Errored;
54
+ use PHPUnit\Event\Test\ErroredSubscriber;
55
+ use PHPUnit\Event\Test\Failed;
56
+ use PHPUnit\Event\Test\FailedSubscriber;
57
+ use PHPUnit\Event\Test\MarkedIncomplete;
58
+ use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
59
+ use PHPUnit\Event\Test\Passed;
60
+ use PHPUnit\Event\Test\PassedSubscriber;
61
+ use PHPUnit\Event\Test\Prepared;
62
+ use PHPUnit\Event\Test\PreparedSubscriber;
63
+ use PHPUnit\Event\Test\Skipped;
64
+ use PHPUnit\Event\Test\SkippedSubscriber;
65
+ use PHPUnit\Event\TestRunner\Started as RunnerStarted;
66
+ use PHPUnit\Event\TestRunner\StartedSubscriber as RunnerStartedSubscriber;
67
+ use PHPUnit\Runner\Extension\Extension;
68
+ use PHPUnit\Runner\Extension\Facade;
69
+ use PHPUnit\Runner\Extension\ParameterCollection;
70
+ use PHPUnit\TextUI\Configuration\Configuration;
71
+
72
+ final class DescryEmitter
73
+ {
74
+ /** Must equal ENVELOPE_VERSION in test-runner-collector.ts. A mismatch is refused there, loudly. */
75
+ public const ENVELOPE_VERSION = 1;
76
+
77
+ /** Test ids that reached `Prepared`, i.e. whose body actually started. */
78
+ private static array $prepared = [];
79
+
80
+ public static function envelope(): void
81
+ {
82
+ self::write(['type' => 'descry:envelope', 'data' => ['version' => self::ENVELOPE_VERSION]]);
83
+ }
84
+
85
+ public static function started(Prepared $event): void
86
+ {
87
+ self::$prepared[$event->test()->id()] = true;
88
+ self::write(['type' => 'test:start', 'data' => self::common($event)]);
89
+ }
90
+
91
+ public static function passed(Passed $event): void
92
+ {
93
+ self::write(['type' => 'test:pass', 'data' => self::common($event)]);
94
+ }
95
+
96
+ public static function failed(Failed $event): void
97
+ {
98
+ $data = self::common($event);
99
+ $data['details'] = ['error' => ['message' => $event->throwable()->asString()]];
100
+ self::write(['type' => 'test:fail', 'data' => $data]);
101
+ }
102
+
103
+ public static function errored(Errored $event): void
104
+ {
105
+ $data = self::common($event);
106
+ // Named apart from a failed assertion. PHPUnit is the only layer that
107
+ // knows which of the two happened, and they send a developer to
108
+ // different places.
109
+ $data['details'] = ['error' => ['message' => $event->throwable()->asString(), 'failureType' => 'error']];
110
+ self::write(['type' => 'test:fail', 'data' => $data]);
111
+ }
112
+
113
+ public static function skipped(Skipped $event): void
114
+ {
115
+ $data = self::common($event);
116
+ $data['skip'] = true;
117
+ // Read from the event order, never from the message. A `#[Requires*]`
118
+ // attribute is evaluated before the test and produces no `Prepared`;
119
+ // `markTestSkipped()` runs inside the body, which necessarily did.
120
+ $data['skipReason'] = isset(self::$prepared[$event->test()->id()]) ? 'precondition-unmet' : 'disabled';
121
+ $data['message'] = $event->message();
122
+ self::write(['type' => 'test:pass', 'data' => $data]);
123
+ }
124
+
125
+ public static function incomplete(MarkedIncomplete $event): void
126
+ {
127
+ $data = self::common($event);
128
+ $data['skip'] = true;
129
+ // PHPUnit's own word is "incomplete": a placeholder for behaviour
130
+ // that is not written. node:test's `todo`, not its `skip`.
131
+ $data['skipReason'] = 'not-implemented';
132
+ $data['message'] = $event->throwable()->message();
133
+ self::write(['type' => 'test:pass', 'data' => $data]);
134
+ }
135
+
136
+ /**
137
+ * `name` is the method (with a data-set suffix for a data provider);
138
+ * `qualifiedName` is `Class::method`, which is what a graph resolves
139
+ * against. Both come off `TestMethod`, so nothing is stripped or
140
+ * reconstructed downstream.
141
+ */
142
+ private static function common($event): array
143
+ {
144
+ $test = $event->test();
145
+ $data = ['name' => $test->name()];
146
+
147
+ if ($test instanceof \PHPUnit\Event\Code\TestMethod) {
148
+ // `id()` is `Class::method` plus a data-provider suffix when
149
+ // there is one; `name()` is the bare method and drops it. The
150
+ // display name keeps the suffix so two data sets are two
151
+ // results, and `qualifiedName` is the declaring method so both
152
+ // resolve to one graph node. Neither is reconstructed by string
153
+ // surgery on the other beyond removing the class prefix `id()`
154
+ // itself put there.
155
+ $prefix = $test->className() . '::';
156
+ $data['name'] = str_starts_with($test->id(), $prefix) ? substr($test->id(), strlen($prefix)) : $test->name();
157
+ $data['qualifiedName'] = $prefix . $test->methodName();
158
+ $data['file'] = $test->file();
159
+ $data['line'] = $test->line();
160
+ }
161
+
162
+ return $data;
163
+ }
164
+
165
+ /**
166
+ * **The leading newline is not cosmetic**, and it was measured on pytest
167
+ * rather than guessed at here: a runner that writes progress characters
168
+ * with no trailing newline puts a report on the same line as one, and
169
+ * the line stops being JSON. A collector then reads every result as
170
+ * noise while the starts arrive intact — a reporter that looks healthy
171
+ * and reports nothing. PHPUnit's default progress output does exactly
172
+ * that (`.`, `F`, `S`, `I`, in columns of 63).
173
+ */
174
+ private static function write(array $object): void
175
+ {
176
+ fwrite(STDOUT, "\n" . json_encode($object, JSON_UNESCAPED_SLASHES) . "\n");
177
+ fflush(STDOUT);
178
+ }
179
+ }
180
+
181
+ final class DescryPhpUnitExtension implements Extension
182
+ {
183
+ public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void
184
+ {
185
+ $facade->registerSubscribers(
186
+ new class implements RunnerStartedSubscriber {
187
+ public function notify(RunnerStarted $event): void { DescryEmitter::envelope(); }
188
+ },
189
+ new class implements PreparedSubscriber {
190
+ public function notify(Prepared $event): void { DescryEmitter::started($event); }
191
+ },
192
+ new class implements PassedSubscriber {
193
+ public function notify(Passed $event): void { DescryEmitter::passed($event); }
194
+ },
195
+ new class implements FailedSubscriber {
196
+ public function notify(Failed $event): void { DescryEmitter::failed($event); }
197
+ },
198
+ new class implements ErroredSubscriber {
199
+ public function notify(Errored $event): void { DescryEmitter::errored($event); }
200
+ },
201
+ new class implements SkippedSubscriber {
202
+ public function notify(Skipped $event): void { DescryEmitter::skipped($event); }
203
+ },
204
+ new class implements MarkedIncompleteSubscriber {
205
+ public function notify(MarkedIncomplete $event): void { DescryEmitter::incomplete($event); }
206
+ },
207
+ );
208
+ }
209
+ }
@@ -0,0 +1,397 @@
1
+ """§17's pytest reporter — a plugin writing the same NDJSON envelope
2
+ ``reporter.ts``, ``jest-reporter.ts``, ``vitest-reporter.ts`` and
3
+ ``DescryJUnitListener.java`` write (RT-107).
4
+
5
+ ## Why a plugin and not ``--junit-xml``
6
+
7
+ pytest can emit JUnit XML, and it is the wrong source for the same reason
8
+ RT-088 gave for JUnit itself: the XML collapses distinct outcomes into one
9
+ bare ``<skipped>``. pytest has **more** to lose than JUnit does — ``skip``,
10
+ ``skipif``, ``xfail`` and a runtime ``pytest.skip()`` are four different
11
+ facts about coverage, and the report format keeps two of them apart at best.
12
+
13
+ ``pytest_runtest_logreport`` is pytest's own structured reporting hook. It
14
+ receives the outcome pytest itself computed, so nothing here re-derives a
15
+ verdict from printed text.
16
+
17
+ ## The four outcomes, and where each one is *read* rather than inferred
18
+
19
+ ``skipReason`` is set here, never downstream, for the same reason
20
+ ``isSuite`` is: only the reporter knows what its runner can tell apart.
21
+
22
+ =========================== ========================== ====================
23
+ what the developer wrote what it means for coverage ``skipReason``
24
+ =========================== ========================== ====================
25
+ ``@pytest.mark.skip`` switched off deliberately ``disabled``
26
+ ``@pytest.mark.skipif(c)`` the environment said no ``precondition-unmet``
27
+ ``@pytest.mark.xfail`` known-broken, not absent ``not-implemented``
28
+ ``pytest.skip()`` at runtime ran real code, then bailed ``precondition-unmet``
29
+ =========================== ========================== ====================
30
+
31
+ ``xfail`` -> ``not-implemented`` is the one worth arguing. The vocabulary
32
+ defines it as "declared as absent rather than switched off", and an ``xfail``
33
+ test is written, does run, and is declared not to work yet — which is what
34
+ ``test.todo`` means on node:test, and is not what ``test.skip`` means. The
35
+ alternative reading (``disabled``) would put ``xfail`` in the same bucket as
36
+ ``skip``, and the whole reason this reporter exists is that those two answer
37
+ "is this code covered" differently.
38
+
39
+ A runtime ``pytest.skip()`` gets ``precondition-unmet`` and not
40
+ ``unspecified``: it is JUnit's ``ABORTED`` exactly — the test body executed
41
+ and bailed on an assumption.
42
+
43
+ ## Which marker applied is read at collection, not parsed from the report
44
+
45
+ ``report.longrepr`` for a skip is a ``(path, lineno, reason)`` tuple whose
46
+ reason is a *string* like ``"Skipped: no database"``. Distinguishing
47
+ ``skip`` from ``skipif`` by parsing it would be a heuristic over prose. So
48
+ markers are read off the item in ``pytest_collection_modifyitems``, where
49
+ pytest hands over the real ``Mark`` objects, and looked up by ``nodeid``.
50
+
51
+ The same pass reads ``module.__name__``, the class and the original function
52
+ name, so ``qualifiedName`` is what pytest knows rather than a nodeid this
53
+ plugin reformatted into something that looks like a dotted path.
54
+
55
+ ## Setup and teardown failures are real failures
56
+
57
+ A fixture that raises produces ``failed`` on the ``setup`` phase and the test
58
+ body never runs. Reported as ``test:fail``: it is the single signal that the
59
+ test did not execute, and dropping it to make the phase handling uniform
60
+ would trade a real failure for a silence. The ``call`` phase is what
61
+ produces passes, so a passed ``setup``/``teardown`` is not forwarded — the
62
+ same asymmetry the translator applies to node:test containers and the JUnit
63
+ listener applies to its own.
64
+
65
+ ## The envelope version
66
+
67
+ Declared before the first event, as a **number**: ``{"version": "1"}`` is
68
+ read as a broken reporter rather than a matching one. Keep in step with
69
+ ``ENVELOPE_VERSION`` in ``test-runner-collector.ts`` — that file is the
70
+ authority, not this docstring.
71
+ """
72
+
73
+ import json
74
+ import os
75
+ import sys
76
+ import threading
77
+
78
+ import pytest
79
+
80
+ #: Must equal ENVELOPE_VERSION in test-runner-collector.ts. A mismatch is refused there, loudly.
81
+ ENVELOPE_VERSION = 1
82
+
83
+ #: nodeid -> what collection could see about this item. Populated once, read per report.
84
+ _COLLECTED: dict = {}
85
+
86
+ #: Set from the config so writes can step outside pytest's own output capture.
87
+ _CAPTURE_MANAGER = None
88
+
89
+ _COVERAGE_DIRECTORY_VARIABLE = "DESCRY_COVERAGE_DIR"
90
+ _COVERAGE_ROOT_VARIABLE = "DESCRY_COVERAGE_ROOT"
91
+ _EXCLUDED_SEGMENTS = frozenset(
92
+ {"site-packages", "dist-packages", "node_modules", ".venv", "venv", "vendor", "__pycache__", ".tox", ".git"}
93
+ )
94
+
95
+ _RECORDER = None
96
+ _TEST_FILES = set()
97
+
98
+
99
+ class _LineRecorder:
100
+ def __init__(self, root):
101
+ self.root = root
102
+ self.executed = {}
103
+ self.mechanism = None
104
+ self.tool_id = None
105
+ self.notes = []
106
+ self.relative_cache = {}
107
+ self.own_file = os.path.realpath(__file__)
108
+
109
+ def relative(self, filename):
110
+ cached = self.relative_cache.get(filename)
111
+ if cached is not None:
112
+ return cached
113
+ result = ""
114
+ try:
115
+ if filename.endswith(".py") and os.path.isabs(filename):
116
+ real = os.path.realpath(filename)
117
+ prefix = self.root + os.sep
118
+ if real.startswith(prefix) and real != self.own_file:
119
+ parts = real[len(prefix):].split(os.sep)
120
+ if not _EXCLUDED_SEGMENTS.intersection(parts):
121
+ result = "/".join(parts)
122
+ except (OSError, ValueError):
123
+ result = ""
124
+ self.relative_cache[filename] = result
125
+ return result
126
+
127
+ def lines_of(self, relative):
128
+ lines = self.executed.get(relative)
129
+ if lines is None:
130
+ lines = self.executed[relative] = set()
131
+ return lines
132
+
133
+ def on_line(self, code, line_number):
134
+ relative = self.relative(code.co_filename)
135
+ if relative and line_number > 0:
136
+ self.lines_of(relative).add(line_number)
137
+ return sys.monitoring.DISABLE
138
+
139
+ def global_trace(self, frame, event, argument):
140
+ relative = self.relative(frame.f_code.co_filename)
141
+ if not relative:
142
+ return None
143
+ lines = self.lines_of(relative)
144
+
145
+ def local_trace(local_frame, local_event, local_argument):
146
+ if local_event == "line":
147
+ lines.add(local_frame.f_lineno)
148
+ return local_trace
149
+
150
+ return local_trace
151
+
152
+ def start_monitoring(self):
153
+ monitoring = getattr(sys, "monitoring", None)
154
+ if monitoring is None:
155
+ return False
156
+ for tool_id in dict.fromkeys((monitoring.COVERAGE_ID, 3, 4, 2, 5)):
157
+ try:
158
+ monitoring.use_tool_id(tool_id, "descry-coverage")
159
+ except ValueError:
160
+ continue
161
+ monitoring.register_callback(tool_id, monitoring.events.LINE, self.on_line)
162
+ monitoring.set_events(tool_id, monitoring.events.LINE)
163
+ self.mechanism = "monitoring"
164
+ self.tool_id = tool_id
165
+ if tool_id != monitoring.COVERAGE_ID:
166
+ self.notes.append(
167
+ "sys.monitoring COVERAGE_ID was taken by another tool, so tool id %d was used" % tool_id
168
+ )
169
+ return True
170
+ self.notes.append("every sys.monitoring tool id in use, fell back to sys.settrace")
171
+ return False
172
+
173
+ def start_settrace(self):
174
+ if sys.gettrace() is not None:
175
+ raise RuntimeError("another sys.settrace tracer (a debugger or coverage tool) is already active")
176
+ sys.settrace(self.global_trace)
177
+ threading.settrace(self.global_trace)
178
+ self.mechanism = "settrace"
179
+ self.notes.append("recorded with sys.settrace, which slows the test run noticeably")
180
+
181
+ def start(self):
182
+ if not self.start_monitoring():
183
+ self.start_settrace()
184
+
185
+ def stop(self):
186
+ if self.mechanism == "monitoring":
187
+ monitoring = sys.monitoring
188
+ monitoring.set_events(self.tool_id, 0)
189
+ monitoring.register_callback(self.tool_id, monitoring.events.LINE, None)
190
+ monitoring.free_tool_id(self.tool_id)
191
+ elif self.mechanism == "settrace":
192
+ sys.settrace(None)
193
+ threading.settrace(None)
194
+
195
+
196
+ def _coverage_directory():
197
+ return os.environ.get(_COVERAGE_DIRECTORY_VARIABLE) or None
198
+
199
+
200
+ def _write_coverage_file(payload):
201
+ directory = _coverage_directory()
202
+ if directory is None:
203
+ return
204
+ pid = os.getpid()
205
+ temporary = os.path.join(directory, ".coverage-%d.tmp" % pid)
206
+ with open(temporary, "w", encoding="utf-8") as handle:
207
+ json.dump(payload, handle)
208
+ os.replace(temporary, os.path.join(directory, "coverage-%d.json" % pid))
209
+
210
+
211
+ def _write_unavailable(reason):
212
+ try:
213
+ _write_coverage_file({"unavailable": reason})
214
+ except Exception:
215
+ return
216
+
217
+
218
+ def _start_recording():
219
+ global _RECORDER
220
+ root = os.environ.get(_COVERAGE_ROOT_VARIABLE)
221
+ if _RECORDER is not None or _coverage_directory() is None or not root:
222
+ return
223
+ recorder = _LineRecorder(os.path.realpath(root))
224
+ try:
225
+ recorder.start()
226
+ except Exception as error:
227
+ _write_unavailable("%s: %s" % (type(error).__name__, error))
228
+ return
229
+ _RECORDER = recorder
230
+
231
+
232
+ def _finish_recording():
233
+ global _RECORDER
234
+ recorder, _RECORDER = _RECORDER, None
235
+ if recorder is None:
236
+ return
237
+ try:
238
+ recorder.stop()
239
+ files = {
240
+ relative: sorted(lines)
241
+ for relative, lines in recorder.executed.items()
242
+ if relative not in _TEST_FILES and relative.rsplit("/", 1)[-1] != "conftest.py"
243
+ }
244
+ _write_coverage_file({"tool": recorder.mechanism, "notes": recorder.notes, "files": files})
245
+ except Exception as error:
246
+ _write_unavailable("%s: %s" % (type(error).__name__, error))
247
+
248
+
249
+ def _note_test_files(items):
250
+ recorder = _RECORDER
251
+ if recorder is None:
252
+ return
253
+ for item in items:
254
+ path = getattr(item, "path", None)
255
+ filename = str(path) if path is not None else str(item.fspath)
256
+ relative = recorder.relative(filename)
257
+ if relative:
258
+ _TEST_FILES.add(relative)
259
+
260
+
261
+ @pytest.hookimpl(tryfirst=True)
262
+ def pytest_load_initial_conftests(early_config, parser, args):
263
+ _start_recording()
264
+
265
+
266
+ def _emit(line: str) -> None:
267
+ """Write one NDJSON line to the real stdout.
268
+
269
+ Two hazards, both measured rather than anticipated.
270
+
271
+ **Capture.** pytest captures stdout at the *file-descriptor* level by
272
+ default (``--capture=fd``), which swallows a plain ``print`` from a hook
273
+ as readily as one from a test body. The capture manager's own context is
274
+ the supported way out, and is what pytest's terminal reporter uses for
275
+ the same purpose.
276
+
277
+ **The leading newline is not cosmetic.** pytest's progress characters
278
+ are written with no trailing newline, so a report emitted from
279
+ ``pytest_runtest_logreport`` lands on the same line::
280
+
281
+ .{"type": "test:pass", ...}
282
+ F{"type": "test:fail", ...}
283
+
284
+ That line is not JSON. The collector would read every result as noise
285
+ and keep only ``test:start`` — measured exactly that way on the first
286
+ run of this plugin: eight starts, zero outcomes, and no error anywhere,
287
+ because unparseable stdout is *supposed* to be ignorable. A reporter
288
+ that silently reports only starts is the shape §17 exists to ban, so the
289
+ fix is to always begin on a fresh line rather than to depend on a
290
+ verbosity setting where it happens not to bite.
291
+ """
292
+ text = "\n" + line + "\n"
293
+ if _CAPTURE_MANAGER is None:
294
+ sys.stdout.write(text)
295
+ sys.stdout.flush()
296
+ return
297
+ with _CAPTURE_MANAGER.global_and_fixture_disabled():
298
+ sys.stdout.write(text)
299
+ sys.stdout.flush()
300
+
301
+
302
+ def pytest_configure(config):
303
+ global _CAPTURE_MANAGER
304
+ _CAPTURE_MANAGER = config.pluginmanager.getplugin("capturemanager")
305
+ _start_recording()
306
+ _emit(json.dumps({"type": "descry:envelope", "data": {"version": ENVELOPE_VERSION}}))
307
+
308
+
309
+ def pytest_collection_modifyitems(items):
310
+ """Read the markers and the real names off each item, once."""
311
+ _note_test_files(items)
312
+ for item in items:
313
+ markers = {mark.name for mark in item.iter_markers()}
314
+ module = getattr(item, "module", None)
315
+ cls = getattr(item, "cls", None)
316
+ parts = [getattr(module, "__name__", None), getattr(cls, "__name__", None), getattr(item, "originalname", None) or item.name]
317
+ location = getattr(item, "location", (None, None, None))
318
+ _COLLECTED[item.nodeid] = {
319
+ "markers": markers,
320
+ # `originalname` is the declaring function for a parameterised
321
+ # case, whose `name` carries its arguments. Both are emitted so
322
+ # the translator is never left to strip anything.
323
+ "qualifiedName": ".".join(p for p in parts if p),
324
+ "file": _absolute_file(item, location[0]),
325
+ # pytest's `location` line is 0-based; every other reporter in
326
+ # this envelope emits 1-based, which is what an editor opens.
327
+ "line": None if location[1] is None else location[1] + 1,
328
+ }
329
+
330
+
331
+ def _absolute_file(item, relative):
332
+ path = getattr(item, "path", None) or getattr(item, "fspath", None)
333
+ return str(path) if path is not None else relative
334
+
335
+
336
+ def _common(report) -> dict:
337
+ collected = _COLLECTED.get(report.nodeid, {})
338
+ data = {"name": report.nodeid.split("::")[-1]}
339
+ for key in ("qualifiedName", "file", "line"):
340
+ value = collected.get(key)
341
+ if value is not None:
342
+ data[key] = value
343
+ return data
344
+
345
+
346
+ def _skip_reason(report) -> str:
347
+ markers = _COLLECTED.get(report.nodeid, {}).get("markers", set())
348
+ # `wasxfail` is set by pytest on any report an xfail marker governed,
349
+ # including one raised by `pytest.xfail()` at runtime, so it is checked
350
+ # before the markers rather than after.
351
+ if hasattr(report, "wasxfail") or "xfail" in markers:
352
+ return "not-implemented"
353
+ if "skipif" in markers:
354
+ return "precondition-unmet"
355
+ if "skip" in markers:
356
+ return "disabled"
357
+ # No marker: `pytest.skip()` or `pytest.importorskip()` inside the test
358
+ # body. It ran real code and bailed on an assumption — JUnit's ABORTED.
359
+ return "precondition-unmet"
360
+
361
+
362
+ def pytest_runtest_logreport(report):
363
+ if report.when == "setup" and report.outcome == "passed":
364
+ _emit(json.dumps({"type": "test:start", "data": _common(report)}))
365
+ return
366
+
367
+ if report.outcome == "skipped":
368
+ data = _common(report)
369
+ data["skip"] = True
370
+ data["skipReason"] = _skip_reason(report)
371
+ if report.longreprtext:
372
+ data["message"] = report.longreprtext
373
+ _emit(json.dumps({"type": "test:pass", "data": data}))
374
+ return
375
+
376
+ if report.outcome == "failed":
377
+ data = _common(report)
378
+ data["details"] = {"error": {"message": report.longreprtext}}
379
+ if report.when != "call":
380
+ # A fixture failure. Named in the payload rather than collapsed,
381
+ # because "the test failed" and "the test never ran" are
382
+ # different facts and only this reporter can tell them apart.
383
+ data["details"]["error"]["failureType"] = f"{report.when}Error"
384
+ _emit(json.dumps({"type": "test:fail", "data": data}))
385
+ return
386
+
387
+ if report.when == "call" and report.outcome == "passed":
388
+ data = _common(report)
389
+ if report.duration is not None:
390
+ data["details"] = {"duration_ms": report.duration * 1000}
391
+ _emit(json.dumps({"type": "test:pass", "data": data}))
392
+
393
+
394
+ @pytest.hookimpl(trylast=True)
395
+ def pytest_unconfigure(config): # noqa: ARG001 - pytest's signature
396
+ _finish_recording()
397
+ _COLLECTED.clear()