@executablemd/testing 0.3.1

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/esm/core/mod.js +55 -0
  3. package/esm/core/src/api.js +31 -0
  4. package/esm/core/src/collect.js +24 -0
  5. package/esm/core/src/component-api.js +47 -0
  6. package/esm/core/src/content-context.js +23 -0
  7. package/esm/core/src/deno-compiler.js +51 -0
  8. package/esm/core/src/errors.js +13 -0
  9. package/esm/core/src/eval-context.js +13 -0
  10. package/esm/core/src/eval-handler.js +79 -0
  11. package/esm/core/src/eval-interpolate.js +63 -0
  12. package/esm/core/src/eval-transform.js +378 -0
  13. package/esm/core/src/execute.js +378 -0
  14. package/esm/core/src/expand.js +988 -0
  15. package/esm/core/src/frontmatter.js +98 -0
  16. package/esm/core/src/heal.js +29 -0
  17. package/esm/core/src/interpolate.js +39 -0
  18. package/esm/core/src/modifiers/daemon.js +60 -0
  19. package/esm/core/src/modifiers/persist.js +35 -0
  20. package/esm/core/src/modifiers/timeout.js +55 -0
  21. package/esm/core/src/modifiers.js +113 -0
  22. package/esm/core/src/output/normalize.js +33 -0
  23. package/esm/core/src/output/terminal.js +20 -0
  24. package/esm/core/src/render.js +30 -0
  25. package/esm/core/src/replay-stream.js +58 -0
  26. package/esm/core/src/sample-api.js +45 -0
  27. package/esm/core/src/scanner.js +632 -0
  28. package/esm/core/src/temp-file-compiler.js +68 -0
  29. package/esm/core/src/types.js +7 -0
  30. package/esm/core/src/validate.js +82 -0
  31. package/esm/durable-streams/combinators.js +227 -0
  32. package/esm/durable-streams/context.js +13 -0
  33. package/esm/durable-streams/divergence.js +56 -0
  34. package/esm/durable-streams/each.js +193 -0
  35. package/esm/durable-streams/effect.js +273 -0
  36. package/esm/durable-streams/ephemeral.js +80 -0
  37. package/esm/durable-streams/errors.js +92 -0
  38. package/esm/durable-streams/http-stream.js +207 -0
  39. package/esm/durable-streams/mod.js +34 -0
  40. package/esm/durable-streams/operations.js +88 -0
  41. package/esm/durable-streams/replay-guard.js +87 -0
  42. package/esm/durable-streams/replay-index.js +132 -0
  43. package/esm/durable-streams/run.js +137 -0
  44. package/esm/durable-streams/serialize.js +65 -0
  45. package/esm/durable-streams/stream.js +56 -0
  46. package/esm/durable-streams/types.js +10 -0
  47. package/esm/package.json +3 -0
  48. package/esm/runtime/apis.js +237 -0
  49. package/esm/runtime/find-free-port.js +33 -0
  50. package/esm/runtime/mod.js +20 -0
  51. package/esm/testing/npm/src/_dnt.polyfills.js +1 -0
  52. package/esm/testing/npm/src/_dnt.shims.js +57 -0
  53. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/almost_equals.js +47 -0
  54. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/array_includes.js +65 -0
  55. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/assert.js +23 -0
  56. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/assertion_error.js +30 -0
  57. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/equal.js +194 -0
  58. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/equals.js +61 -0
  59. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/exists.js +27 -0
  60. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/fail.js +20 -0
  61. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/false.js +23 -0
  62. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/greater.js +29 -0
  63. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.js +29 -0
  64. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/instance_of.js +52 -0
  65. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/is_error.js +53 -0
  66. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/less.js +28 -0
  67. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/less_or_equal.js +29 -0
  68. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/match.js +26 -0
  69. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/mod.js +45 -0
  70. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_equals.js +39 -0
  71. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_instance_of.js +29 -0
  72. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_match.js +26 -0
  73. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.js +32 -0
  74. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/object_match.js +177 -0
  75. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/rejects.js +53 -0
  76. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/strict_equals.js +58 -0
  77. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/string_includes.js +26 -0
  78. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/throws.js +44 -0
  79. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/unimplemented.js +20 -0
  80. package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/unreachable.js +20 -0
  81. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/build_message.js +116 -0
  82. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/diff.js +277 -0
  83. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/diff_str.js +181 -0
  84. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/format.js +70 -0
  85. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/styles.js +207 -0
  86. package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/types.js +3 -0
  87. package/esm/testing/npm/src/mod.js +27 -0
  88. package/esm/testing/npm/src/src/assertions.js +250 -0
  89. package/esm/testing/npm/src/src/handlers.js +245 -0
  90. package/esm/testing/npm/src/src/journal.js +152 -0
  91. package/esm/testing/npm/src/src/test-api.js +33 -0
  92. package/esm/testing/npm/src/src/use-testing.js +79 -0
  93. package/esm/testing/npm/src/src/vocabulary.js +111 -0
  94. package/package.json +35 -0
  95. package/types/_dnt.polyfills.d.ts +6 -0
  96. package/types/_dnt.shims.d.ts +1 -0
  97. package/types/deps/jsr.io/@std/assert/1.0.19/almost_equals.d.ts +29 -0
  98. package/types/deps/jsr.io/@std/assert/1.0.19/array_includes.d.ts +28 -0
  99. package/types/deps/jsr.io/@std/assert/1.0.19/assert.d.ts +16 -0
  100. package/types/deps/jsr.io/@std/assert/1.0.19/assertion_error.d.ts +25 -0
  101. package/types/deps/jsr.io/@std/assert/1.0.19/equal.d.ts +24 -0
  102. package/types/deps/jsr.io/@std/assert/1.0.19/equals.d.ts +36 -0
  103. package/types/deps/jsr.io/@std/assert/1.0.19/exists.d.ts +17 -0
  104. package/types/deps/jsr.io/@std/assert/1.0.19/fail.d.ts +14 -0
  105. package/types/deps/jsr.io/@std/assert/1.0.19/false.d.ts +18 -0
  106. package/types/deps/jsr.io/@std/assert/1.0.19/greater.d.ts +19 -0
  107. package/types/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.d.ts +19 -0
  108. package/types/deps/jsr.io/@std/assert/1.0.19/instance_of.d.ts +22 -0
  109. package/types/deps/jsr.io/@std/assert/1.0.19/is_error.d.ts +24 -0
  110. package/types/deps/jsr.io/@std/assert/1.0.19/less.d.ts +18 -0
  111. package/types/deps/jsr.io/@std/assert/1.0.19/less_or_equal.d.ts +19 -0
  112. package/types/deps/jsr.io/@std/assert/1.0.19/match.d.ts +17 -0
  113. package/types/deps/jsr.io/@std/assert/1.0.19/mod.d.ts +43 -0
  114. package/types/deps/jsr.io/@std/assert/1.0.19/not_equals.d.ts +26 -0
  115. package/types/deps/jsr.io/@std/assert/1.0.19/not_instance_of.d.ts +19 -0
  116. package/types/deps/jsr.io/@std/assert/1.0.19/not_match.d.ts +17 -0
  117. package/types/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.d.ts +22 -0
  118. package/types/deps/jsr.io/@std/assert/1.0.19/object_match.d.ts +28 -0
  119. package/types/deps/jsr.io/@std/assert/1.0.19/rejects.d.ts +41 -0
  120. package/types/deps/jsr.io/@std/assert/1.0.19/strict_equals.d.ts +23 -0
  121. package/types/deps/jsr.io/@std/assert/1.0.19/string_includes.d.ts +17 -0
  122. package/types/deps/jsr.io/@std/assert/1.0.19/throws.d.ts +44 -0
  123. package/types/deps/jsr.io/@std/assert/1.0.19/unimplemented.d.ts +14 -0
  124. package/types/deps/jsr.io/@std/assert/1.0.19/unreachable.d.ts +14 -0
  125. package/types/deps/jsr.io/@std/internal/1.0.14/build_message.d.ts +79 -0
  126. package/types/deps/jsr.io/@std/internal/1.0.14/diff.d.ts +139 -0
  127. package/types/deps/jsr.io/@std/internal/1.0.14/diff_str.d.ts +98 -0
  128. package/types/deps/jsr.io/@std/internal/1.0.14/format.d.ts +28 -0
  129. package/types/deps/jsr.io/@std/internal/1.0.14/styles.d.ts +158 -0
  130. package/types/deps/jsr.io/@std/internal/1.0.14/types.d.ts +24 -0
  131. package/types/mod.d.ts +29 -0
  132. package/types/src/assertions.d.ts +55 -0
  133. package/types/src/handlers.d.ts +18 -0
  134. package/types/src/journal.d.ts +29 -0
  135. package/types/src/test-api.d.ts +54 -0
  136. package/types/src/use-testing.d.ts +25 -0
  137. package/types/src/vocabulary.d.ts +34 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `<Testing>` and `<Test>` handlers (specs/testing-spec.md).
3
+ *
4
+ * `createTestHandlers` is the internal dependency-injection seam for the
5
+ * fixed 20-second test timeout: the public vocabulary always constructs
6
+ * handlers with 20_000; tests construct them directly with a small timeout.
7
+ */
8
+ import { ensure, scoped, spawn, suspend, withResolvers } from "effection";
9
+ import { timebox } from "@effectionx/timebox";
10
+ import { unbox, useEvalScope } from "@effectionx/scope-eval";
11
+ import { AssertionError } from "../deps/jsr.io/@std/assert/1.0.19/mod.js";
12
+ import { Component, env, evalScope } from "@executablemd/core";
13
+ import { Test, boundary, inTest, record, testing } from "./test-api.js";
14
+ import { AssertionDiagnostic, expandAssertion } from "./assertions.js";
15
+ import { persistBoundaryOutcome, persistTestResult } from "./journal.js";
16
+ // ---------------------------------------------------------------------------
17
+ // Contained-failure carriers
18
+ // ---------------------------------------------------------------------------
19
+ /** An ErrorSegment raised anywhere inside a test body. */
20
+ class RaisedSegmentError extends Error {
21
+ name = "RaisedSegmentError";
22
+ segment;
23
+ constructor(segment) {
24
+ super(segment.message);
25
+ this.segment = segment;
26
+ }
27
+ }
28
+ /** A failure while dismantling an established test scope or lease. */
29
+ class TeardownError extends Error {
30
+ name = "TeardownError";
31
+ constructor(cause) {
32
+ super(cause instanceof Error ? cause.message : String(cause));
33
+ this.cause = cause;
34
+ }
35
+ }
36
+ /**
37
+ * Host a child EvalScope in a dedicated task inside the parent EvalScope.
38
+ * The child inherits the parent's middleware, but its lifetime belongs to
39
+ * the TEST: `parentScope.eval(() => useEvalScope())` alone would tie the
40
+ * worker to the parent scope, leaking test-installed middleware into later
41
+ * tests. The suspended task keeps the child alive until the lease is halted.
42
+ */
43
+ function* leaseChildEvalScope(parentScope) {
44
+ const published = withResolvers();
45
+ const boxed = yield* parentScope.eval(function* () {
46
+ return yield* spawn(function* () {
47
+ published.resolve(yield* useEvalScope());
48
+ yield* suspend();
49
+ });
50
+ });
51
+ return { scope: yield* published.operation, task: unbox(boxed) };
52
+ }
53
+ export function createTestHandlers(options) {
54
+ const { timeoutMs } = options;
55
+ function* expandTesting(invocation, ctx) {
56
+ return yield* scoped(function* () {
57
+ const local = [];
58
+ yield* Test.around({
59
+ testing: () => true,
60
+ // deno-lint-ignore require-yield
61
+ *results() {
62
+ return local;
63
+ },
64
+ *record([result], next) {
65
+ local.push(result);
66
+ yield* next(result);
67
+ },
68
+ }, { at: "min" });
69
+ const report = yield* ctx.expand(invocation.children);
70
+ // Journal the outcome before the root Close so a full replay can
71
+ // restore it without re-expanding this boundary.
72
+ const outcome = yield* persistBoundaryOutcome({
73
+ tests: local.length,
74
+ failed: local.filter((result) => result.status === "fail").length,
75
+ }, formatLocation(invocation));
76
+ yield* boundary(outcome);
77
+ return report;
78
+ });
79
+ }
80
+ function* expandTest(invocation, ctx) {
81
+ if (!(yield* testing)) {
82
+ return [];
83
+ }
84
+ if (yield* inTest) {
85
+ // Returned while the ENCLOSING test's raise interceptor is still
86
+ // active — the hook's re-raise fails the current test.
87
+ const error = {
88
+ type: "error",
89
+ message: "Nested <Test> elements are invalid.",
90
+ source: "Test",
91
+ };
92
+ return [error];
93
+ }
94
+ const name = typeof invocation.props.name === "string" ? invocation.props.name : undefined;
95
+ const location = formatLocation(invocation);
96
+ const parentEnv = yield* env;
97
+ const parentScope = yield* evalScope;
98
+ if (!parentScope) {
99
+ const result = yield* persistTestResult(failResult(name, location, {
100
+ kind: "error",
101
+ message: "<Test> requires an eval scope in context.",
102
+ }));
103
+ yield* record(result);
104
+ return [failureDiagnostic(result, { detail: true })];
105
+ }
106
+ // ONE stable binding environment, created before middleware install —
107
+ // the accessor returns the same object on every read, so <Capture>
108
+ // writes persist for the assertion that follows. Caller-projected
109
+ // bindings merge UNDER the current environment (core's precedence,
110
+ // expand.ts §content projection), so a <Test> projected through
111
+ // <Content /> still sees the caller's eval bindings.
112
+ const testEnv = {
113
+ values: {
114
+ ...(ctx.projectedEnv?.values ?? {}),
115
+ ...(parentEnv?.values ?? {}),
116
+ },
117
+ };
118
+ const testOutput = [];
119
+ let bodyError;
120
+ let timedOut = false;
121
+ let established = false;
122
+ try {
123
+ yield* scoped(function* () {
124
+ const lease = yield* leaseChildEvalScope(parentScope);
125
+ // Halt the lease during this scope's teardown, before the next test
126
+ // can start. A throwing halt propagates as a teardown failure.
127
+ yield* ensure(() => lease.task.halt());
128
+ yield* Component.around({
129
+ env: () => testEnv,
130
+ evalScope: () => lease.scope,
131
+ }, { at: "min" });
132
+ yield* Test.around({ inTest: () => true }, { at: "min" });
133
+ // ErrorSegments fail the test. Outer instrumentation (default "max")
134
+ // so nested { at: "min" } policies cannot shadow it: every raise in
135
+ // the body — components, <Output> regions, code blocks, imports,
136
+ // validation, nested <Test> — arrives here first.
137
+ yield* Component.around({
138
+ // deno-lint-ignore require-yield
139
+ *raise([segment]) {
140
+ throw new RaisedSegmentError(segment);
141
+ },
142
+ });
143
+ established = true;
144
+ const boxed = yield* timebox(timeoutMs, function* () {
145
+ for (const child of invocation.children) {
146
+ try {
147
+ testOutput.push(...(yield* ctx.expand([child])));
148
+ }
149
+ catch (error) {
150
+ bodyError = error;
151
+ throw error;
152
+ }
153
+ }
154
+ });
155
+ if (boxed.timeout) {
156
+ timedOut = true;
157
+ }
158
+ });
159
+ }
160
+ catch (outer) {
161
+ if (bodyError === undefined && !timedOut) {
162
+ // Setup failures (lease creation, middleware install) are unexpected
163
+ // errors; only failures dismantling an ESTABLISHED scope/lease are
164
+ // teardown failures.
165
+ bodyError = established ? new TeardownError(outer) : outer;
166
+ }
167
+ }
168
+ // Journal the result before the root Close. On partial replay the
169
+ // stored record wins over the recomputation (short-circuited effects
170
+ // can change what the re-run observes, e.g. a halted exec no longer
171
+ // times out), keeping the original outcome authoritative.
172
+ const result = yield* persistTestResult(classify(name, location, bodyError, timedOut, timeoutMs));
173
+ yield* record(result);
174
+ if (result.status === "fail") {
175
+ // Containment invariant: a completed test returns only text segments.
176
+ // The hook re-raises returned ErrorSegments under the AMBIENT policy —
177
+ // after this test's interception scope has ended — so raised segments
178
+ // are formatted into the diagnostic instead of returned raw.
179
+ if (bodyError instanceof AssertionDiagnostic) {
180
+ // The assertion's own diagnostic (built when it threw) follows the
181
+ // output produced before the failure, then the test-level line.
182
+ testOutput.push({ type: "text", content: bodyError.diagnostic });
183
+ testOutput.push(failureDiagnostic(result, { detail: false }));
184
+ }
185
+ else {
186
+ testOutput.push(failureDiagnostic(result, { detail: true }));
187
+ }
188
+ }
189
+ return testOutput;
190
+ }
191
+ return { expandTesting, expandTest, expandAssertion };
192
+ }
193
+ // ---------------------------------------------------------------------------
194
+ // Result classification and diagnostics
195
+ // ---------------------------------------------------------------------------
196
+ function formatLocation(invocation) {
197
+ const position = invocation.position;
198
+ if (!position) {
199
+ return "unknown";
200
+ }
201
+ const at = `${position.line}:${position.column}`;
202
+ return position.path ? `${position.path}:${at}` : at;
203
+ }
204
+ function failResult(name, location, error) {
205
+ return { status: "fail", name, location, error };
206
+ }
207
+ function classify(name, location, bodyError, timedOut, timeoutMs) {
208
+ if (bodyError === undefined && !timedOut) {
209
+ return { status: "pass", name, location };
210
+ }
211
+ if (timedOut && bodyError === undefined) {
212
+ return failResult(name, location, {
213
+ kind: "timeout",
214
+ message: `test timed out after ${timeoutMs / 1000} seconds`,
215
+ });
216
+ }
217
+ if (bodyError instanceof AssertionDiagnostic) {
218
+ return failResult(name, location, {
219
+ kind: "assertion",
220
+ message: bodyError.message,
221
+ actual: bodyError.detail.actual,
222
+ expected: bodyError.detail.expected,
223
+ });
224
+ }
225
+ if (bodyError instanceof AssertionError) {
226
+ return failResult(name, location, { kind: "assertion", message: bodyError.message });
227
+ }
228
+ if (bodyError instanceof TeardownError) {
229
+ return failResult(name, location, { kind: "teardown", message: bodyError.message });
230
+ }
231
+ const message = bodyError instanceof Error ? bodyError.message : String(bodyError);
232
+ return failResult(name, location, { kind: "error", message });
233
+ }
234
+ function failureDiagnostic(result, options) {
235
+ const title = result.name ? `**${result.name}**` : `test at ${result.location}`;
236
+ const error = result.error;
237
+ const lines = [`> ❌ Test ${title} failed (${error?.kind ?? "error"}): ${error?.message ?? ""}`];
238
+ if (options.detail && error?.actual !== undefined) {
239
+ lines.push(`> actual: ${error.actual}`);
240
+ }
241
+ if (options.detail && error?.expected !== undefined) {
242
+ lines.push(`> expected: ${error.expected}`);
243
+ }
244
+ return { type: "text", content: `\n${lines.join("\n")}\n` };
245
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Durable testing records (specs/testing-spec.md §Testing Mode).
3
+ *
4
+ * Completed tests and explicit `<Testing>` boundary outcomes are journaled
5
+ * as testing-owned durable operations while document expansion runs —
6
+ * before the root Close event. On a full replay of a completed journal,
7
+ * `durableRun` returns the stored root result without re-expanding the
8
+ * document, so no test would ever re-record; the stored records are
9
+ * restored from the stream instead. On partial replay the document
10
+ * re-expands and each record replays in place, recording exactly once in
11
+ * discovery order.
12
+ *
13
+ * Record identities are deterministic, derived from source position.
14
+ */
15
+ import { createDurableOperation } from "@executablemd/durable-streams";
16
+ const TEST_RESULT = "test_result";
17
+ const TESTING_BOUNDARY = "testing_boundary";
18
+ export function* persistTestResult(result) {
19
+ const stored = yield createDurableOperation({ type: TEST_RESULT, name: `test:${result.location}` },
20
+ // deno-lint-ignore require-yield
21
+ function* () {
22
+ return serializeTestResult(result);
23
+ });
24
+ const parsed = parseTestResult(stored);
25
+ if (!parsed) {
26
+ throw new Error(`journaled test_result for "${result.location}" has an unexpected shape`);
27
+ }
28
+ return parsed;
29
+ }
30
+ export function* persistBoundaryOutcome(outcome, location) {
31
+ const stored = yield createDurableOperation({ type: TESTING_BOUNDARY, name: `testing:${location}` },
32
+ // deno-lint-ignore require-yield
33
+ function* () {
34
+ return { tests: outcome.tests, failed: outcome.failed };
35
+ });
36
+ const parsed = parseBoundaryOutcome(stored);
37
+ if (!parsed) {
38
+ throw new Error(`journaled testing_boundary for "${location}" has an unexpected shape`);
39
+ }
40
+ return parsed;
41
+ }
42
+ /**
43
+ * Read testing records from a journal that already holds a root Close
44
+ * event — the confirmed-full-replay case. Returns undefined for a live or
45
+ * partial journal, where expansion itself (re)records each result.
46
+ */
47
+ export function* readCompletedRun(stream) {
48
+ const events = yield* stream.readAll();
49
+ const completed = events.some((event) => event.type === "close" && event.coroutineId === "root");
50
+ if (!completed) {
51
+ return undefined;
52
+ }
53
+ const results = [];
54
+ const boundaries = [];
55
+ for (const event of events) {
56
+ if (event.type !== "yield" || event.result.status !== "ok") {
57
+ continue;
58
+ }
59
+ if (event.description.type === TEST_RESULT) {
60
+ const parsed = parseTestResult(event.result.value);
61
+ if (parsed) {
62
+ results.push(parsed);
63
+ }
64
+ }
65
+ else if (event.description.type === TESTING_BOUNDARY) {
66
+ const parsed = parseBoundaryOutcome(event.result.value);
67
+ if (parsed) {
68
+ boundaries.push(parsed);
69
+ }
70
+ }
71
+ }
72
+ return { results, boundaries };
73
+ }
74
+ function serializeTestResult(result) {
75
+ const payload = {
76
+ status: result.status,
77
+ location: result.location,
78
+ };
79
+ if (result.name !== undefined) {
80
+ payload.name = result.name;
81
+ }
82
+ if (result.error) {
83
+ const error = {
84
+ kind: result.error.kind,
85
+ message: result.error.message,
86
+ };
87
+ if (result.error.actual !== undefined) {
88
+ error.actual = result.error.actual;
89
+ }
90
+ if (result.error.expected !== undefined) {
91
+ error.expected = result.error.expected;
92
+ }
93
+ payload.error = error;
94
+ }
95
+ return payload;
96
+ }
97
+ function isRecord(value) {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value);
99
+ }
100
+ function optionalString(value) {
101
+ return value === undefined || typeof value === "string";
102
+ }
103
+ const ERROR_KINDS = ["assertion", "timeout", "teardown", "error"];
104
+ function parseTestResult(value) {
105
+ if (!isRecord(value)) {
106
+ return undefined;
107
+ }
108
+ const { status, location, name, error } = value;
109
+ if (status !== "pass" && status !== "fail") {
110
+ return undefined;
111
+ }
112
+ if (typeof location !== "string" || !optionalString(name)) {
113
+ return undefined;
114
+ }
115
+ const result = { status, location };
116
+ if (name !== undefined) {
117
+ result.name = name;
118
+ }
119
+ if (error !== undefined) {
120
+ if (!isRecord(error)) {
121
+ return undefined;
122
+ }
123
+ const { kind, message, actual, expected } = error;
124
+ if (typeof kind !== "string" || !ERROR_KINDS.includes(kind)) {
125
+ return undefined;
126
+ }
127
+ if (typeof message !== "string" ||
128
+ !optionalString(actual) ||
129
+ !optionalString(expected) ||
130
+ (kind !== "assertion" && kind !== "timeout" && kind !== "teardown" && kind !== "error")) {
131
+ return undefined;
132
+ }
133
+ result.error = { kind, message };
134
+ if (actual !== undefined) {
135
+ result.error.actual = actual;
136
+ }
137
+ if (expected !== undefined) {
138
+ result.error.expected = expected;
139
+ }
140
+ }
141
+ return result;
142
+ }
143
+ function parseBoundaryOutcome(value) {
144
+ if (!isRecord(value)) {
145
+ return undefined;
146
+ }
147
+ const { tests, failed } = value;
148
+ if (typeof tests !== "number" || typeof failed !== "number" || tests < 0 || failed < 0) {
149
+ return undefined;
150
+ }
151
+ return { tests, failed };
152
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Test Api — contextual operations for testing mode (specs/testing-spec.md).
3
+ *
4
+ * `testing` is the activation switch: false by default, true beneath
5
+ * `<Testing>` (or root activation via a `useTesting()` session).
6
+ * Both perform the identical `Test.around({ testing: () => true })` install,
7
+ * which is what makes `xmd test` equivalent to wrapping the entrypoint in
8
+ * `<Testing>`.
9
+ *
10
+ * `record` delegates outward through nested collectors, so every enclosing
11
+ * `<Testing>` boundary and the run-level collector observe each completed
12
+ * test. `boundary` reports each `<Testing>` element's aggregate outcome.
13
+ */
14
+ import { createApi } from "@effectionx/context-api";
15
+ export const Test = createApi("Test", {
16
+ testing: false,
17
+ inTest: false,
18
+ verbose: false,
19
+ sessionActive: false,
20
+ // deno-lint-ignore require-yield
21
+ *record(_result) { },
22
+ // deno-lint-ignore require-yield
23
+ *results() {
24
+ return [];
25
+ },
26
+ // deno-lint-ignore require-yield
27
+ *boundary(_outcome) { },
28
+ });
29
+ export const { testing, inTest, verbose, sessionActive, record, results, boundary } = Test.operations;
30
+ /** A document execution failed its testing outcome (test failures or zero tests). */
31
+ export class TestFailureError extends Error {
32
+ name = "TestFailureError";
33
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * useTesting — scope-local testing composition (specs/testing-spec.md).
3
+ *
4
+ * One `useTesting()` session per execution scope: it installs the testing
5
+ * vocabulary, the collection and completion-policy middleware, and root
6
+ * activation, then returns a session handle whose `results` operation
7
+ * snapshots completed tests in discovery order. Every install is removed
8
+ * with the session's Effection scope.
9
+ *
10
+ * ```ts
11
+ * const tests = yield* useTesting();
12
+ * const execution = yield* execute(options);
13
+ * const outcome = yield* execution; // Result<string>
14
+ * const results = yield* tests.results;
15
+ * ```
16
+ */
17
+ import { Execution } from "@executablemd/core";
18
+ import { sessionActive, Test, TestFailureError } from "./test-api.js";
19
+ import { decorateCompletion, installTestingVocabulary } from "./vocabulary.js";
20
+ export function* useTesting(options) {
21
+ if (yield* sessionActive) {
22
+ throw new Error("useTesting() is already active in this scope — use one session per execution scope");
23
+ }
24
+ yield* Test.around({ sessionActive: () => true });
25
+ yield* installTestingVocabulary(options);
26
+ const collected = [];
27
+ yield* Test.around({
28
+ // deno-lint-ignore require-yield
29
+ *results() {
30
+ return [...collected];
31
+ },
32
+ *record([result], next) {
33
+ collected.push(result);
34
+ yield* next(result);
35
+ },
36
+ });
37
+ // Root activation — the same install <Testing> performs for its subtree,
38
+ // applied to the whole execution (`xmd test` ≡ root <Testing>).
39
+ yield* Test.around({ testing: () => true });
40
+ // Completion policy: an otherwise successful execution becomes
41
+ // Err(TestFailureError) after its output closes when tests failed or none
42
+ // were discovered. A core Err passes through unchanged, and `results`
43
+ // stays available either way.
44
+ //
45
+ // One execute() per session: results are cumulative across the session,
46
+ // so a second document would inherit the first document's outcomes (a
47
+ // zero-test document after a passing one would succeed). Fail clearly
48
+ // BEFORE a handle exists — the pre-handle throw path.
49
+ let executed = false;
50
+ yield* Execution.around({
51
+ *execute([executeOptions], next) {
52
+ if (executed) {
53
+ throw new Error("a useTesting() session supports one execute() call — start a new session for another document");
54
+ }
55
+ executed = true;
56
+ const inner = yield* next(executeOptions);
57
+ return decorateCompletion(inner, () => {
58
+ if (collected.length === 0) {
59
+ return new TestFailureError("no tests were discovered");
60
+ }
61
+ const failed = collected.filter((result) => result.status === "fail");
62
+ if (failed.length > 0) {
63
+ const details = failed
64
+ .map((result) => ` ${result.name ?? result.location}: ${result.error?.message ?? "failed"}`)
65
+ .join("\n");
66
+ return new TestFailureError(`${failed.length} of ${collected.length} tests failed\n${details}`);
67
+ }
68
+ return undefined;
69
+ });
70
+ },
71
+ });
72
+ return {
73
+ results: {
74
+ *[Symbol.iterator]() {
75
+ return Object.freeze([...collected]);
76
+ },
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Vocabulary registration (specs/testing-spec.md).
3
+ *
4
+ * Teaches the expansion loop the testing words — `<Testing>`, `<Test>`, and
5
+ * the assertion components — via the core `expandInvocation` hook, and
6
+ * decorates the core Execution Api so explicit `<Testing>` boundaries affect
7
+ * the execution outcome even when root testing is inactive.
8
+ *
9
+ * Registration is distinct from activation: installing the vocabulary
10
+ * leaves `testing` false, so `<Test>` skips and assertions stay usable.
11
+ * Root activation is `useTesting()`'s job.
12
+ *
13
+ * Installs are scope-local — call this inside a bounded scope (one CLI
14
+ * command invocation, one `scoped()` block).
15
+ */
16
+ import { Err } from "effection";
17
+ import { Component, Execution } from "@executablemd/core";
18
+ import { boundary, record, Test, TestFailureError } from "./test-api.js";
19
+ import { readCompletedRun } from "./journal.js";
20
+ import { ASSERTIONS } from "./assertions.js";
21
+ import { createTestHandlers } from "./handlers.js";
22
+ const TEST_TIMEOUT_MS = 20_000;
23
+ export function* installTestingVocabulary(options) {
24
+ yield* installHandlers(createTestHandlers({ timeoutMs: TEST_TIMEOUT_MS }), options);
25
+ }
26
+ /**
27
+ * Install a specific handler set. Internal seam: tests inject handlers built
28
+ * with a short timeout; the public path always uses the fixed 20 seconds.
29
+ */
30
+ export function* installHandlers(handlers, options) {
31
+ if (options?.verbose) {
32
+ yield* Test.around({ verbose: () => true });
33
+ }
34
+ yield* Component.around({
35
+ *expandInvocation([invocation, ctx], next) {
36
+ if (invocation.name === "Testing") {
37
+ return { segments: yield* handlers.expandTesting(invocation, ctx) };
38
+ }
39
+ if (invocation.name === "Test") {
40
+ return { segments: yield* handlers.expandTest(invocation, ctx) };
41
+ }
42
+ const assertion = ASSERTIONS.get(invocation.name);
43
+ if (assertion) {
44
+ return { segments: yield* handlers.expandAssertion(assertion, invocation, ctx) };
45
+ }
46
+ return yield* next(invocation, ctx);
47
+ },
48
+ });
49
+ yield* Execution.around({
50
+ *execute([executeOptions], next) {
51
+ // Fresh boundary collection per execution: outcomes reported by
52
+ // explicit <Testing> elements in THIS run decide this run's Result.
53
+ const boundaries = [];
54
+ yield* Test.around({
55
+ *boundary([outcome], nextBoundary) {
56
+ boundaries.push(outcome);
57
+ yield* nextBoundary(outcome);
58
+ },
59
+ });
60
+ // Confirmed full replay: durableRun returns the stored root result
61
+ // without re-expanding, so nothing would re-record. Restore the
62
+ // journaled testing records into the current collectors — through
63
+ // the same record/boundary operations live expansion uses, so every
64
+ // session collector and observer sees them in discovery order. A
65
+ // live or partial journal (no root Close) hydrates nothing;
66
+ // re-expansion records each result exactly once via its durable
67
+ // operation.
68
+ const replayed = yield* readCompletedRun(executeOptions.stream);
69
+ if (replayed) {
70
+ for (const result of replayed.results) {
71
+ yield* record(result);
72
+ }
73
+ for (const outcome of replayed.boundaries) {
74
+ yield* boundary(outcome);
75
+ }
76
+ }
77
+ const inner = yield* next(executeOptions);
78
+ return decorateCompletion(inner, () => {
79
+ const failed = boundaries.filter((b) => b.failed > 0);
80
+ if (failed.length > 0) {
81
+ return new TestFailureError(`${failed.reduce((n, b) => n + b.failed, 0)} test(s) failed in <Testing>`);
82
+ }
83
+ if (boundaries.some((b) => b.tests === 0)) {
84
+ return new TestFailureError("a <Testing> boundary discovered no tests");
85
+ }
86
+ return undefined;
87
+ });
88
+ },
89
+ });
90
+ }
91
+ /**
92
+ * Map an execution's completion: an `Ok` becomes `Err(failure())` when the
93
+ * policy reports one, after the inner completion — and therefore its closed
94
+ * output stream — settles. An existing `Err` passes through unchanged.
95
+ */
96
+ export function decorateCompletion(inner, failure) {
97
+ return {
98
+ output: inner.output,
99
+ *[Symbol.iterator]() {
100
+ const result = yield* inner;
101
+ if (!result.ok) {
102
+ return result;
103
+ }
104
+ const error = failure();
105
+ if (error) {
106
+ return Err(error);
107
+ }
108
+ return result;
109
+ },
110
+ };
111
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@executablemd/testing",
3
+ "version": "0.3.1",
4
+ "description": "Testing vocabulary for executable.md documents: <Testing>, <Test>, and assertion components.",
5
+ "homepage": "https://executable.md",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/taras/executable.md.git"
9
+ },
10
+ "license": "MIT",
11
+ "bugs": {
12
+ "url": "https://github.com/taras/executable.md/issues"
13
+ },
14
+ "module": "./esm/mod.js",
15
+ "types": "./types/mod.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./types/mod.d.ts",
20
+ "default": "./esm/mod.js"
21
+ }
22
+ }
23
+ },
24
+ "scripts": {},
25
+ "dependencies": {
26
+ "@effectionx/context-api": "0.6.0",
27
+ "@effectionx/scope-eval": "0.1.3",
28
+ "@effectionx/timebox": "0.4.3",
29
+ "@executablemd/core": "^0.3.1",
30
+ "@executablemd/durable-streams": "^0.3.1",
31
+ "effection": "4.1.0-alpha.7",
32
+ "@std/assert": "npm:@jsr/std__assert@^1.0.17"
33
+ },
34
+ "_generatedBy": "dnt@dev"
35
+ }
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ interface Error {
3
+ cause?: unknown;
4
+ }
5
+ }
6
+ export {};
@@ -0,0 +1 @@
1
+ export declare const dntGlobalThis: Omit<typeof globalThis, never>;