@devosurf/tesser-testing 0.1.0-alpha.0
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 +202 -0
- package/README.md +24 -0
- package/package.json +24 -0
- package/src/cassette.ts +86 -0
- package/src/engine.ts +571 -0
- package/src/index.ts +48 -0
- package/src/invoke-action.ts +105 -0
- package/src/sample.ts +205 -0
- package/src/smoke.ts +78 -0
- package/src/spy.ts +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Direct action invocation for connector tests (and the connector-authoring skill):
|
|
2
|
+
// build a real ActionCtx over a fake/recorded fetch and run one action with full
|
|
3
|
+
// input/output validation — the same path the runtime takes.
|
|
4
|
+
|
|
5
|
+
import type { ActionCtx, ConnectorInstance } from "@devosurf/tesser-sdk/connector";
|
|
6
|
+
import {
|
|
7
|
+
actionAtPath,
|
|
8
|
+
createHttpClient,
|
|
9
|
+
runAction,
|
|
10
|
+
type TesserHttpConfig,
|
|
11
|
+
} from "@devosurf/tesser-sdk/internal";
|
|
12
|
+
|
|
13
|
+
export interface InvokeActionOptions {
|
|
14
|
+
/** Fake fetch (or a cassette-backed one). Defaults to real fetch — live tests only. */
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
/** Credential fields for ctx.auth + custom signers. Defaults to a dummy api key. */
|
|
17
|
+
auth?: { kind?: "oauth2" | "apiKey" | "basic" | "custom"; mode?: string; fields?: Record<string, string> };
|
|
18
|
+
/** Override the connector/provider base URL. */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
idempotencyKey?: string;
|
|
21
|
+
/** Apply the credential to outbound requests; defaults to Authorization: Bearer. */
|
|
22
|
+
applyAuth?: TesserHttpConfig["applyAuth"];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function invokeAction(
|
|
26
|
+
connector: ConnectorInstance<any, any>,
|
|
27
|
+
path: string,
|
|
28
|
+
input: unknown,
|
|
29
|
+
opts: InvokeActionOptions = {},
|
|
30
|
+
): Promise<unknown> {
|
|
31
|
+
const segments = path.split(".");
|
|
32
|
+
const def = actionAtPath(connector, segments);
|
|
33
|
+
if (!def) {
|
|
34
|
+
throw new TypeError(`invokeAction: connector "${connector.id}" has no action "${path}"`);
|
|
35
|
+
}
|
|
36
|
+
const spec = connector.__connector;
|
|
37
|
+
const baseUrl =
|
|
38
|
+
opts.baseUrl ??
|
|
39
|
+
spec.baseUrl ??
|
|
40
|
+
(typeof spec.provider === "object" ? spec.provider.baseUrl : undefined);
|
|
41
|
+
const fields = opts.auth?.fields ?? { api_key: "test-key" };
|
|
42
|
+
const http = createHttpClient({
|
|
43
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
44
|
+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
|
|
45
|
+
...(spec.defaultHeaders !== undefined ? { defaultHeaders: spec.defaultHeaders } : {}),
|
|
46
|
+
applyAuth:
|
|
47
|
+
opts.applyAuth ??
|
|
48
|
+
(({ headers }) => {
|
|
49
|
+
const token = fields["access_token"] ?? fields["api_key"] ?? fields["token"];
|
|
50
|
+
if (token !== undefined) headers.set("authorization", `Bearer ${token}`);
|
|
51
|
+
}),
|
|
52
|
+
...(def.classifyError !== undefined ? { classifyError: def.classifyError } : {}),
|
|
53
|
+
});
|
|
54
|
+
const ctx: ActionCtx = {
|
|
55
|
+
http,
|
|
56
|
+
auth: { kind: opts.auth?.kind ?? "apiKey", fields, ...(opts.auth?.mode !== undefined ? { mode: opts.auth.mode } : {}) },
|
|
57
|
+
logger: { info() {}, warn() {}, error() {} },
|
|
58
|
+
...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
59
|
+
};
|
|
60
|
+
return runAction(def, ctx, input, `${connector.id}.${path}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FakeRequest {
|
|
64
|
+
url: URL;
|
|
65
|
+
method: string;
|
|
66
|
+
headers: Headers;
|
|
67
|
+
body: string;
|
|
68
|
+
}
|
|
69
|
+
// Not `unknown` — a union with unknown collapses and kills contextual typing of handlers.
|
|
70
|
+
type FakeRouteResult = Response | string | number | boolean | null | object;
|
|
71
|
+
export type FakeRoute = FakeRouteResult | ((req: FakeRequest) => FakeRouteResult | Promise<FakeRouteResult>);
|
|
72
|
+
|
|
73
|
+
/** A fetch stub from a route table: "METHOD /path" (with :param segments) → response. */
|
|
74
|
+
export function fakeFetch(
|
|
75
|
+
routes: Record<string, FakeRoute>,
|
|
76
|
+
): typeof fetch & { requests: Array<{ method: string; url: string; body: string; headers: Headers }> } {
|
|
77
|
+
const requests: Array<{ method: string; url: string; body: string; headers: Headers }> = [];
|
|
78
|
+
const impl = (async (input: unknown, init?: { method?: string; headers?: unknown; body?: unknown }) => {
|
|
79
|
+
const url = new URL(String(input instanceof Request ? input.url : input));
|
|
80
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
81
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
82
|
+
const headers = new Headers(init?.headers as never);
|
|
83
|
+
requests.push({ method, url: url.toString(), body, headers });
|
|
84
|
+
for (const [route, handler] of Object.entries(routes)) {
|
|
85
|
+
const [m, p] = route.split(" ", 2) as [string, string];
|
|
86
|
+
const matches =
|
|
87
|
+
m.toUpperCase() === method &&
|
|
88
|
+
(p === url.pathname || new RegExp(`^${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:\w+/g, "[^/]+")}$`).test(url.pathname));
|
|
89
|
+
if (matches) {
|
|
90
|
+
const result = typeof handler === "function" ? await handler({ url, method, headers, body }) : handler;
|
|
91
|
+
if (result instanceof Response) return result;
|
|
92
|
+
return new Response(JSON.stringify(result), {
|
|
93
|
+
status: 200,
|
|
94
|
+
headers: { "content-type": "application/json" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return new Response(JSON.stringify({ error: `no fake route for ${method} ${url.pathname}` }), {
|
|
99
|
+
status: 404,
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
});
|
|
102
|
+
}) as never as typeof fetch & { requests: typeof requests };
|
|
103
|
+
(impl as { requests: typeof requests }).requests = requests;
|
|
104
|
+
return impl;
|
|
105
|
+
}
|
package/src/sample.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Schema-driven sample + edge-case generation (ADR-0008). Works through JSON Schema:
|
|
2
|
+
// any validator that can describe itself as JSON Schema participates; with zod we use
|
|
3
|
+
// its built-in conversion. Everything degrades gracefully to "ask for an explicit mock".
|
|
4
|
+
|
|
5
|
+
import type { Schema } from "@devosurf/tesser-sdk";
|
|
6
|
+
|
|
7
|
+
type JsonSchema = {
|
|
8
|
+
type?: string | string[];
|
|
9
|
+
properties?: Record<string, JsonSchema>;
|
|
10
|
+
required?: string[];
|
|
11
|
+
items?: JsonSchema | JsonSchema[];
|
|
12
|
+
prefixItems?: JsonSchema[];
|
|
13
|
+
enum?: unknown[];
|
|
14
|
+
const?: unknown;
|
|
15
|
+
anyOf?: JsonSchema[];
|
|
16
|
+
oneOf?: JsonSchema[];
|
|
17
|
+
allOf?: JsonSchema[];
|
|
18
|
+
nullable?: boolean;
|
|
19
|
+
default?: unknown;
|
|
20
|
+
minimum?: number;
|
|
21
|
+
maximum?: number;
|
|
22
|
+
exclusiveMinimum?: number;
|
|
23
|
+
exclusiveMaximum?: number;
|
|
24
|
+
minLength?: number;
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
minItems?: number;
|
|
27
|
+
format?: string;
|
|
28
|
+
pattern?: string;
|
|
29
|
+
additionalProperties?: JsonSchema | boolean;
|
|
30
|
+
[k: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Best-effort JSON Schema for a Standard Schema. Currently: zod (vendor "zod") via its
|
|
34
|
+
* own toJSONSchema; otherwise undefined. */
|
|
35
|
+
export function toJsonSchema(schema: Schema<unknown>): JsonSchema | undefined {
|
|
36
|
+
const std = (schema as { "~standard"?: { vendor?: string } })["~standard"];
|
|
37
|
+
if (!std) return undefined;
|
|
38
|
+
try {
|
|
39
|
+
if (std.vendor === "zod") {
|
|
40
|
+
const z = schema as unknown as {
|
|
41
|
+
constructor?: unknown;
|
|
42
|
+
};
|
|
43
|
+
// zod v4 exposes conversion as a static on the module; reach it through the
|
|
44
|
+
// schema's own registry-free helper if present.
|
|
45
|
+
const anySchema = z as { toJSONSchema?: () => unknown };
|
|
46
|
+
if (typeof anySchema.toJSONSchema === "function") {
|
|
47
|
+
return anySchema.toJSONSchema() as JsonSchema;
|
|
48
|
+
}
|
|
49
|
+
// Fall back to importing zod lazily — present whenever a zod schema is in play.
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Async variant that can lazily import zod for conversion. */
|
|
60
|
+
export async function toJsonSchemaAsync(schema: Schema<unknown>): Promise<JsonSchema | undefined> {
|
|
61
|
+
const direct = toJsonSchema(schema);
|
|
62
|
+
if (direct) return direct;
|
|
63
|
+
const std = (schema as { "~standard"?: { vendor?: string } })["~standard"];
|
|
64
|
+
if (std?.vendor === "zod") {
|
|
65
|
+
try {
|
|
66
|
+
const zod = (await import("zod")) as unknown as {
|
|
67
|
+
toJSONSchema?: (s: unknown, opts?: unknown) => unknown;
|
|
68
|
+
z?: { toJSONSchema?: (s: unknown, opts?: unknown) => unknown };
|
|
69
|
+
};
|
|
70
|
+
const convert = zod.toJSONSchema ?? zod.z?.toJSONSchema;
|
|
71
|
+
if (convert) return convert(schema, { unrepresentable: "any" }) as JsonSchema;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SAMPLE_STRINGS: Record<string, string> = {
|
|
80
|
+
email: "sample@example.com",
|
|
81
|
+
uri: "https://example.com/sample",
|
|
82
|
+
url: "https://example.com/sample",
|
|
83
|
+
uuid: "00000000-0000-4000-8000-000000000000",
|
|
84
|
+
"date-time": "2026-01-01T00:00:00.000Z",
|
|
85
|
+
date: "2026-01-01",
|
|
86
|
+
time: "00:00:00",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function sampleFromJsonSchema(js: JsonSchema, depth = 0): unknown {
|
|
90
|
+
if (depth > 8) return null;
|
|
91
|
+
if (js.default !== undefined) return js.default;
|
|
92
|
+
if (js.const !== undefined) return js.const;
|
|
93
|
+
if (js.enum && js.enum.length > 0) return js.enum[0];
|
|
94
|
+
const variants = js.anyOf ?? js.oneOf;
|
|
95
|
+
if (variants && variants.length > 0) {
|
|
96
|
+
// Prefer a non-null variant for a useful sample.
|
|
97
|
+
const pick = variants.find((v) => v.type !== "null") ?? variants[0];
|
|
98
|
+
return sampleFromJsonSchema(pick as JsonSchema, depth + 1);
|
|
99
|
+
}
|
|
100
|
+
if (js.allOf && js.allOf.length > 0) {
|
|
101
|
+
return js.allOf.reduce<Record<string, unknown>>((acc, part) => {
|
|
102
|
+
const piece = sampleFromJsonSchema(part, depth + 1);
|
|
103
|
+
return typeof piece === "object" && piece !== null
|
|
104
|
+
? { ...acc, ...(piece as Record<string, unknown>) }
|
|
105
|
+
: acc;
|
|
106
|
+
}, {});
|
|
107
|
+
}
|
|
108
|
+
const type = Array.isArray(js.type) ? js.type[0] : js.type;
|
|
109
|
+
switch (type) {
|
|
110
|
+
case "string": {
|
|
111
|
+
if (js.format && SAMPLE_STRINGS[js.format]) return SAMPLE_STRINGS[js.format];
|
|
112
|
+
const min = js.minLength ?? 0;
|
|
113
|
+
const base = "sample";
|
|
114
|
+
return base.length >= min ? base : base + "x".repeat(min - base.length);
|
|
115
|
+
}
|
|
116
|
+
case "number":
|
|
117
|
+
case "integer": {
|
|
118
|
+
let n = 1;
|
|
119
|
+
if (js.minimum !== undefined) n = js.minimum;
|
|
120
|
+
if (js.exclusiveMinimum !== undefined) n = js.exclusiveMinimum + 1;
|
|
121
|
+
if (js.maximum !== undefined && n > js.maximum) n = js.maximum;
|
|
122
|
+
return type === "integer" ? Math.round(n) : n;
|
|
123
|
+
}
|
|
124
|
+
case "boolean":
|
|
125
|
+
return true;
|
|
126
|
+
case "null":
|
|
127
|
+
return null;
|
|
128
|
+
case "array": {
|
|
129
|
+
const itemSchema = Array.isArray(js.items) ? js.items[0] : js.items;
|
|
130
|
+
const count = Math.max(js.minItems ?? 1, 1);
|
|
131
|
+
if (!itemSchema) return [];
|
|
132
|
+
return Array.from({ length: count }, () => sampleFromJsonSchema(itemSchema, depth + 1));
|
|
133
|
+
}
|
|
134
|
+
case "object":
|
|
135
|
+
default: {
|
|
136
|
+
if (js.properties) {
|
|
137
|
+
const out: Record<string, unknown> = {};
|
|
138
|
+
const required = new Set(js.required ?? Object.keys(js.properties));
|
|
139
|
+
for (const [key, prop] of Object.entries(js.properties)) {
|
|
140
|
+
if (required.has(key) || prop.default !== undefined) {
|
|
141
|
+
out[key] = sampleFromJsonSchema(prop, depth + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
if (type === "object" || type === undefined) return {};
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A sample value satisfying the schema, or undefined when it cannot be derived. */
|
|
153
|
+
export async function sampleFromSchema(schema: Schema<unknown>): Promise<unknown | undefined> {
|
|
154
|
+
const js = await toJsonSchemaAsync(schema);
|
|
155
|
+
if (!js) return undefined;
|
|
156
|
+
return sampleFromJsonSchema(js);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface EdgeCase {
|
|
160
|
+
name: string;
|
|
161
|
+
value: unknown;
|
|
162
|
+
/** Whether the schema should accept it — invalid cases assert the unhappy path. */
|
|
163
|
+
expectValid: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Boundary / malformed inputs from a typed contract (ADR-0008, built incrementally). */
|
|
167
|
+
export async function edgeCasesFromSchema(schema: Schema<unknown>): Promise<EdgeCase[]> {
|
|
168
|
+
const js = await toJsonSchemaAsync(schema);
|
|
169
|
+
if (!js) return [];
|
|
170
|
+
const valid = sampleFromJsonSchema(js);
|
|
171
|
+
const cases: EdgeCase[] = [{ name: "minimal valid sample", value: valid, expectValid: true }];
|
|
172
|
+
cases.push({ name: "null payload", value: null, expectValid: (js.type ?? "object") === "null" });
|
|
173
|
+
cases.push({
|
|
174
|
+
name: "wrong primitive (number)",
|
|
175
|
+
value: 42,
|
|
176
|
+
expectValid: js.type === "number" || js.type === "integer",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (js.type === "object" && js.properties && typeof valid === "object" && valid !== null) {
|
|
180
|
+
for (const key of js.required ?? []) {
|
|
181
|
+
const clone = { ...(valid as Record<string, unknown>) };
|
|
182
|
+
delete clone[key];
|
|
183
|
+
cases.push({ name: `missing required "${key}"`, value: clone, expectValid: false });
|
|
184
|
+
}
|
|
185
|
+
for (const [key, prop] of Object.entries(js.properties)) {
|
|
186
|
+
const t = Array.isArray(prop.type) ? prop.type[0] : prop.type;
|
|
187
|
+
if (t === "string" && (js.required ?? []).includes(key)) {
|
|
188
|
+
const minOk = (prop.minLength ?? 0) === 0;
|
|
189
|
+
cases.push({
|
|
190
|
+
name: `empty string "${key}"`,
|
|
191
|
+
value: { ...(valid as Record<string, unknown>), [key]: "" },
|
|
192
|
+
expectValid: minOk && prop.format === undefined && prop.pattern === undefined,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if ((t === "number" || t === "integer") && prop.minimum !== undefined) {
|
|
196
|
+
cases.push({
|
|
197
|
+
name: `below minimum "${key}"`,
|
|
198
|
+
value: { ...(valid as Record<string, unknown>), [key]: prop.minimum - 1 },
|
|
199
|
+
expectValid: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return cases;
|
|
205
|
+
}
|
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The auto-generated default smoke test (ADR-0008): fire the trigger with a sample
|
|
2
|
+
// payload, auto-mock connectors, assert completion + output schema. Zero-config green/red
|
|
3
|
+
// the moment an automation is written — `tesser test` synthesizes one per automation
|
|
4
|
+
// that lacks a colocated test.
|
|
5
|
+
|
|
6
|
+
import type { AutomationDef, OperatorDef } from "@devosurf/tesser-sdk";
|
|
7
|
+
import { executeAutomation, type RunOptions, type TestRunResult } from "./engine.js";
|
|
8
|
+
import { sampleFromSchema } from "./sample.js";
|
|
9
|
+
|
|
10
|
+
export interface SmokeOutcome {
|
|
11
|
+
automation: string;
|
|
12
|
+
passed: boolean;
|
|
13
|
+
result: TestRunResult;
|
|
14
|
+
/** Machine-actionable reason when red (ADR-0008). */
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function smokeTest(def: AutomationDef<any, any, any, any, any, any, any>): Promise<SmokeOutcome> {
|
|
19
|
+
try {
|
|
20
|
+
const models = await smokeModelScripts(def);
|
|
21
|
+
const result = await executeAutomation(def, Object.keys(models).length > 0 ? { models } : {});
|
|
22
|
+
if (result.status === "completed") {
|
|
23
|
+
return { automation: def.id, passed: true, result };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
automation: def.id,
|
|
27
|
+
passed: false,
|
|
28
|
+
result,
|
|
29
|
+
reason:
|
|
30
|
+
result.error?.step !== undefined
|
|
31
|
+
? `step "${result.error.step}" failed: ${result.error.message}`
|
|
32
|
+
: (result.error?.message ?? "run failed"),
|
|
33
|
+
};
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// TestConfigError (e.g. an un-derivable sample, a signal wait with no timeout) is a
|
|
36
|
+
// legitimate smoke failure: the agent gets told exactly what to provide.
|
|
37
|
+
return {
|
|
38
|
+
automation: def.id,
|
|
39
|
+
passed: false,
|
|
40
|
+
result: {
|
|
41
|
+
status: "failed",
|
|
42
|
+
steps: {},
|
|
43
|
+
journal: [],
|
|
44
|
+
calls: {},
|
|
45
|
+
emitted: [],
|
|
46
|
+
slept: [],
|
|
47
|
+
undone: [],
|
|
48
|
+
logs: [],
|
|
49
|
+
error: { name: (err as Error).name, message: (err as Error).message, retryable: false, terminal: true },
|
|
50
|
+
failure: () => ({
|
|
51
|
+
automation: def.id,
|
|
52
|
+
status: "failed",
|
|
53
|
+
error: { name: (err as Error).name, message: (err as Error).message, retryable: false, terminal: true },
|
|
54
|
+
steps: [],
|
|
55
|
+
connectorCalls: [],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
reason: (err as Error).message,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function smokeModelScripts(
|
|
64
|
+
def: AutomationDef<any, any, any, any, any, any, any>,
|
|
65
|
+
): Promise<NonNullable<RunOptions["models"]>> {
|
|
66
|
+
const models: NonNullable<RunOptions["models"]> = {};
|
|
67
|
+
const operators = (def.operators ?? {}) as Record<string, OperatorDef>;
|
|
68
|
+
for (const [operatorKey, op] of Object.entries(operators)) {
|
|
69
|
+
const sample = await sampleFromSchema(op.output).catch(() => undefined);
|
|
70
|
+
(models[operatorKey] ??= {})[op.model] = {
|
|
71
|
+
...(sample !== undefined ? { output: sample as never } : { content: "{}" }),
|
|
72
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
73
|
+
provider: "tesser-smoke",
|
|
74
|
+
model: op.model,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return models;
|
|
78
|
+
}
|
package/src/spy.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A minimal mock-function record compatible with vitest/jest `expect(...).toHaveBeenCalled*`
|
|
2
|
+
// matchers (they accept anything exposing _isMockFunction + .mock.calls). Hand-rolled so
|
|
3
|
+
// @devosurf/tesser-testing does not depend on a test runner.
|
|
4
|
+
|
|
5
|
+
export interface CapturedCall {
|
|
6
|
+
args: unknown[];
|
|
7
|
+
/** Step that was active when the call happened. */
|
|
8
|
+
step?: string;
|
|
9
|
+
action?: string;
|
|
10
|
+
result?: unknown;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Spy {
|
|
15
|
+
(...args: unknown[]): void;
|
|
16
|
+
_isMockFunction: true;
|
|
17
|
+
getMockName(): string;
|
|
18
|
+
mock: {
|
|
19
|
+
calls: unknown[][];
|
|
20
|
+
results: Array<{ type: "return" | "throw"; value: unknown }>;
|
|
21
|
+
instances: unknown[];
|
|
22
|
+
contexts: unknown[];
|
|
23
|
+
invocationCallOrder: number[];
|
|
24
|
+
lastCall: unknown[] | undefined;
|
|
25
|
+
};
|
|
26
|
+
/** Rich capture (step/action attribution) beyond the matcher contract. */
|
|
27
|
+
captured: CapturedCall[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let order = 1;
|
|
31
|
+
|
|
32
|
+
export function createSpy(name: string): Spy {
|
|
33
|
+
const mock: Spy["mock"] = {
|
|
34
|
+
calls: [],
|
|
35
|
+
results: [],
|
|
36
|
+
instances: [],
|
|
37
|
+
contexts: [],
|
|
38
|
+
invocationCallOrder: [],
|
|
39
|
+
lastCall: undefined,
|
|
40
|
+
};
|
|
41
|
+
const captured: CapturedCall[] = [];
|
|
42
|
+
const fn = ((...args: unknown[]) => {
|
|
43
|
+
mock.calls.push(args);
|
|
44
|
+
mock.invocationCallOrder.push(order++);
|
|
45
|
+
mock.lastCall = args;
|
|
46
|
+
}) as Spy;
|
|
47
|
+
fn._isMockFunction = true;
|
|
48
|
+
fn.getMockName = () => name;
|
|
49
|
+
fn.mock = mock;
|
|
50
|
+
fn.captured = captured;
|
|
51
|
+
return fn;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function recordCall(spy: Spy, call: CapturedCall): void {
|
|
55
|
+
spy(...call.args);
|
|
56
|
+
spy.mock.results.push(
|
|
57
|
+
call.error !== undefined
|
|
58
|
+
? { type: "throw", value: call.error }
|
|
59
|
+
: { type: "return", value: call.result },
|
|
60
|
+
);
|
|
61
|
+
spy.captured.push(call);
|
|
62
|
+
}
|