@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.
- package/LICENSE +21 -0
- package/esm/core/mod.js +55 -0
- package/esm/core/src/api.js +31 -0
- package/esm/core/src/collect.js +24 -0
- package/esm/core/src/component-api.js +47 -0
- package/esm/core/src/content-context.js +23 -0
- package/esm/core/src/deno-compiler.js +51 -0
- package/esm/core/src/errors.js +13 -0
- package/esm/core/src/eval-context.js +13 -0
- package/esm/core/src/eval-handler.js +79 -0
- package/esm/core/src/eval-interpolate.js +63 -0
- package/esm/core/src/eval-transform.js +378 -0
- package/esm/core/src/execute.js +378 -0
- package/esm/core/src/expand.js +988 -0
- package/esm/core/src/frontmatter.js +98 -0
- package/esm/core/src/heal.js +29 -0
- package/esm/core/src/interpolate.js +39 -0
- package/esm/core/src/modifiers/daemon.js +60 -0
- package/esm/core/src/modifiers/persist.js +35 -0
- package/esm/core/src/modifiers/timeout.js +55 -0
- package/esm/core/src/modifiers.js +113 -0
- package/esm/core/src/output/normalize.js +33 -0
- package/esm/core/src/output/terminal.js +20 -0
- package/esm/core/src/render.js +30 -0
- package/esm/core/src/replay-stream.js +58 -0
- package/esm/core/src/sample-api.js +45 -0
- package/esm/core/src/scanner.js +632 -0
- package/esm/core/src/temp-file-compiler.js +68 -0
- package/esm/core/src/types.js +7 -0
- package/esm/core/src/validate.js +82 -0
- package/esm/durable-streams/combinators.js +227 -0
- package/esm/durable-streams/context.js +13 -0
- package/esm/durable-streams/divergence.js +56 -0
- package/esm/durable-streams/each.js +193 -0
- package/esm/durable-streams/effect.js +273 -0
- package/esm/durable-streams/ephemeral.js +80 -0
- package/esm/durable-streams/errors.js +92 -0
- package/esm/durable-streams/http-stream.js +207 -0
- package/esm/durable-streams/mod.js +34 -0
- package/esm/durable-streams/operations.js +88 -0
- package/esm/durable-streams/replay-guard.js +87 -0
- package/esm/durable-streams/replay-index.js +132 -0
- package/esm/durable-streams/run.js +137 -0
- package/esm/durable-streams/serialize.js +65 -0
- package/esm/durable-streams/stream.js +56 -0
- package/esm/durable-streams/types.js +10 -0
- package/esm/package.json +3 -0
- package/esm/runtime/apis.js +237 -0
- package/esm/runtime/find-free-port.js +33 -0
- package/esm/runtime/mod.js +20 -0
- package/esm/testing/npm/src/_dnt.polyfills.js +1 -0
- package/esm/testing/npm/src/_dnt.shims.js +57 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/almost_equals.js +47 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/array_includes.js +65 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/assert.js +23 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/assertion_error.js +30 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/equal.js +194 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/equals.js +61 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/exists.js +27 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/fail.js +20 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/false.js +23 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/greater.js +29 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.js +29 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/instance_of.js +52 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/is_error.js +53 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/less.js +28 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/less_or_equal.js +29 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/match.js +26 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/mod.js +45 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_equals.js +39 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_instance_of.js +29 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_match.js +26 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.js +32 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/object_match.js +177 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/rejects.js +53 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/strict_equals.js +58 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/string_includes.js +26 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/throws.js +44 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/unimplemented.js +20 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/assert/1.0.19/unreachable.js +20 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/build_message.js +116 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/diff.js +277 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/diff_str.js +181 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/format.js +70 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/styles.js +207 -0
- package/esm/testing/npm/src/deps/jsr.io/@std/internal/1.0.14/types.js +3 -0
- package/esm/testing/npm/src/mod.js +27 -0
- package/esm/testing/npm/src/src/assertions.js +250 -0
- package/esm/testing/npm/src/src/handlers.js +245 -0
- package/esm/testing/npm/src/src/journal.js +152 -0
- package/esm/testing/npm/src/src/test-api.js +33 -0
- package/esm/testing/npm/src/src/use-testing.js +79 -0
- package/esm/testing/npm/src/src/vocabulary.js +111 -0
- package/package.json +35 -0
- package/types/_dnt.polyfills.d.ts +6 -0
- package/types/_dnt.shims.d.ts +1 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/almost_equals.d.ts +29 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/array_includes.d.ts +28 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/assert.d.ts +16 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/assertion_error.d.ts +25 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/equal.d.ts +24 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/equals.d.ts +36 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/exists.d.ts +17 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/fail.d.ts +14 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/false.d.ts +18 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/greater.d.ts +19 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.d.ts +19 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/instance_of.d.ts +22 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/is_error.d.ts +24 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/less.d.ts +18 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/less_or_equal.d.ts +19 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/match.d.ts +17 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/mod.d.ts +43 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/not_equals.d.ts +26 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/not_instance_of.d.ts +19 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/not_match.d.ts +17 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.d.ts +22 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/object_match.d.ts +28 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/rejects.d.ts +41 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/strict_equals.d.ts +23 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/string_includes.d.ts +17 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/throws.d.ts +44 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/unimplemented.d.ts +14 -0
- package/types/deps/jsr.io/@std/assert/1.0.19/unreachable.d.ts +14 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/build_message.d.ts +79 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/diff.d.ts +139 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/diff_str.d.ts +98 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/format.d.ts +28 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/styles.d.ts +158 -0
- package/types/deps/jsr.io/@std/internal/1.0.14/types.d.ts +24 -0
- package/types/mod.d.ts +29 -0
- package/types/src/assertions.d.ts +55 -0
- package/types/src/handlers.d.ts +18 -0
- package/types/src/journal.d.ts +29 -0
- package/types/src/test-api.d.ts +54 -0
- package/types/src/use-testing.d.ts +25 -0
- 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 @@
|
|
|
1
|
+
export declare const dntGlobalThis: Omit<typeof globalThis, never>;
|