@dypai-ai/mcp 1.0.9 → 1.1.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/package.json +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +27 -97
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +97 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +414 -0
- package/src/tools/sync/push.js +411 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_test_endpoint — test an endpoint by name using its LOCAL YAML.
|
|
3
|
+
*
|
|
4
|
+
* Pre-git-first world: the agent had to pass workflow_code or endpoint_id to
|
|
5
|
+
* the remote test_workflow. Now the YAML lives on disk — so we just take a
|
|
6
|
+
* name + input, read the file, deserialize via the codec, and execute.
|
|
7
|
+
*
|
|
8
|
+
* The big win: you're testing the EDITED version before pushing. Tight
|
|
9
|
+
* iteration loop without round-tripping through dypai_push + remote lookup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile, readdir } from "fs/promises"
|
|
13
|
+
import { join, resolve as resolvePath } from "path"
|
|
14
|
+
import YAML from "yaml"
|
|
15
|
+
import { proxyToolCall } from "../proxy.js"
|
|
16
|
+
import { deserializeEndpoint } from "./codec.js"
|
|
17
|
+
import { readLocalConfig, fetchRemoteState } from "./planner.js"
|
|
18
|
+
import { summarizeTestWorkflowResponse } from "../trace-summarize.js"
|
|
19
|
+
|
|
20
|
+
// ─── Local endpoint file discovery ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function collectFileRefs(doc) {
|
|
23
|
+
const refs = new Set()
|
|
24
|
+
const walk = (node) => {
|
|
25
|
+
if (!node || typeof node !== "object") return
|
|
26
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
27
|
+
for (const [k, v] of Object.entries(node)) {
|
|
28
|
+
if (typeof v === "string" && k.endsWith("_file")) refs.add(v)
|
|
29
|
+
else walk(v)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
walk(doc)
|
|
33
|
+
return Array.from(refs)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function findEndpointByName(rootDir, name) {
|
|
37
|
+
const endpointsDir = join(rootDir, "endpoints")
|
|
38
|
+
const candidates = []
|
|
39
|
+
|
|
40
|
+
async function walk(dir) {
|
|
41
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const full = join(dir, e.name)
|
|
44
|
+
if (e.isDirectory()) await walk(full)
|
|
45
|
+
else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
|
|
46
|
+
candidates.push(full)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await walk(endpointsDir)
|
|
51
|
+
|
|
52
|
+
// 1. Exact doc.name match (authoritative)
|
|
53
|
+
for (const full of candidates) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await readFile(full, "utf8")
|
|
56
|
+
const doc = YAML.parse(raw)
|
|
57
|
+
if (doc?.name === name) return { full, doc }
|
|
58
|
+
} catch { /* skip malformed */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Fallback: filename match (handles case where the agent guessed the file)
|
|
62
|
+
const filenameMatch = candidates.find(p => p.endsWith(`/${name}.yaml`) || p.endsWith(`/${name}.yml`))
|
|
63
|
+
if (filenameMatch) {
|
|
64
|
+
const raw = await readFile(filenameMatch, "utf8")
|
|
65
|
+
const doc = YAML.parse(raw)
|
|
66
|
+
return { full: filenameMatch, doc }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export const dypaiTestEndpointTool = {
|
|
75
|
+
name: "dypai_test_endpoint",
|
|
76
|
+
description:
|
|
77
|
+
"Test an endpoint using its LOCAL YAML (dypai/endpoints/<name>.yaml, any group). " +
|
|
78
|
+
"You only pass the endpoint name + input — the tool reads the YAML, inlines referenced SQL/code/prompt files, " +
|
|
79
|
+
"and runs a debug execution against the engine. Use this to iterate BEFORE dypai_push. " +
|
|
80
|
+
"For jwt endpoints, pass as_user with the UUID to impersonate. " +
|
|
81
|
+
"Returns a summarized per-node trace by default; pass trace_mode: 'full' for the unabbreviated view.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
endpoint: {
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "Endpoint name as declared in its YAML (e.g. 'create-order'). Looked up in dypai/endpoints/** by matching doc.name.",
|
|
88
|
+
},
|
|
89
|
+
input: {
|
|
90
|
+
type: "object",
|
|
91
|
+
description: "Input body for the execution. Maps to ${input.*} placeholders inside the workflow.",
|
|
92
|
+
},
|
|
93
|
+
as_user: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "User UUID to impersonate. Required for jwt endpoints that read ${current_user_id}.",
|
|
96
|
+
},
|
|
97
|
+
trace_mode: {
|
|
98
|
+
type: "string",
|
|
99
|
+
enum: ["smart", "full", "minimal"],
|
|
100
|
+
description: "How to summarize the returned trace. 'smart' (default) surfaces the failing node in detail; 'full' returns everything; 'minimal' returns only status + duration.",
|
|
101
|
+
default: "smart",
|
|
102
|
+
},
|
|
103
|
+
root_dir: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description: "Path to the dypai/ folder (default: ./dypai).",
|
|
106
|
+
default: "./dypai",
|
|
107
|
+
},
|
|
108
|
+
project_id: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Project UUID. Auto-resolved from dypai.config.yaml.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ["endpoint"],
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async execute({ endpoint, input = {}, as_user, trace_mode = "smart", root_dir = "./dypai", project_id } = {}) {
|
|
117
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
118
|
+
|
|
119
|
+
if (!endpoint) {
|
|
120
|
+
return { success: false, error: "endpoint name is required." }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const found = await findEndpointByName(rootDir, endpoint)
|
|
124
|
+
if (!found) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: `Endpoint '${endpoint}' not found under dypai/endpoints/.`,
|
|
128
|
+
hint: "Run dypai_pull to refresh, or check that the YAML's top-level `name` matches.",
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pre-read any *_file references so the codec can inline them
|
|
133
|
+
const refs = collectFileRefs(found.doc)
|
|
134
|
+
const fileMap = {}
|
|
135
|
+
for (const ref of refs) {
|
|
136
|
+
try {
|
|
137
|
+
fileMap[ref] = await readFile(join(rootDir, ref), "utf8")
|
|
138
|
+
} catch (e) {
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: `Referenced file not readable: ${ref} (${e.message})`,
|
|
142
|
+
hint: "The YAML points to a *_file that doesn't exist on disk. Run dypai_pull or create the missing file.",
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Resolve project + credential/tool UUID maps for the codec
|
|
148
|
+
const config = await readLocalConfig(rootDir)
|
|
149
|
+
const targetProjectId = project_id || config?.project_id
|
|
150
|
+
if (!targetProjectId) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
error: "project_id required (set it in dypai.config.yaml or pass it explicitly).",
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let mapsCtx
|
|
158
|
+
try {
|
|
159
|
+
const remote = await fetchRemoteState(targetProjectId)
|
|
160
|
+
mapsCtx = remote.mapsCtx
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
error: `Could not fetch remote state to resolve credential/tool references: ${e.message}`,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Deserialize the local YAML to the engine-shaped workflow_code
|
|
169
|
+
const deserCtx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
|
|
170
|
+
let row
|
|
171
|
+
try {
|
|
172
|
+
row = deserializeEndpoint(found.doc, deserCtx)
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
error: `Failed to deserialize '${endpoint}' from its YAML: ${e.message}`,
|
|
177
|
+
hint: "Run dypai_validate to surface the specific problem.",
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build the call to the remote test_workflow (pass workflow_code inline so
|
|
182
|
+
// we test the LOCAL version, not whatever is currently deployed).
|
|
183
|
+
const execArgs = {
|
|
184
|
+
project_id: targetProjectId,
|
|
185
|
+
workflow_code: row.workflow_code,
|
|
186
|
+
data: input,
|
|
187
|
+
trace_mode, // used by the local MCP enrichment layer
|
|
188
|
+
}
|
|
189
|
+
if (as_user) execArgs.impersonated_user_id = as_user
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const result = await proxyToolCall("test_workflow", execArgs)
|
|
193
|
+
// Summarize the trace just like the direct test_workflow path.
|
|
194
|
+
const summarized = summarizeTestWorkflowResponse(result, trace_mode)
|
|
195
|
+
return {
|
|
196
|
+
endpoint,
|
|
197
|
+
file: found.full.replace(rootDir + "/", ""),
|
|
198
|
+
as_user: as_user || null,
|
|
199
|
+
...summarized,
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
error: `Execution failed: ${e.message}`,
|
|
205
|
+
endpoint,
|
|
206
|
+
hint: "If the error is cryptic, try trace_mode: 'full' or use dypai_trace with the execution_id from the response.",
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_test — YAML-defined tests against endpoints. Zero engine changes:
|
|
3
|
+
* orchestrates `execute_sql` + `test_workflow` (impersonation) + `execute_sql`
|
|
4
|
+
* using what already exists.
|
|
5
|
+
*
|
|
6
|
+
* File layout (committable under dypai/tests/):
|
|
7
|
+
* endpoint: create-order # or endpoint_id: <uuid>
|
|
8
|
+
* tests:
|
|
9
|
+
* - name: "creates order with valid product"
|
|
10
|
+
* setup_sql: INSERT INTO products ...
|
|
11
|
+
* as_user: 11111111-1111-1111-1111-111111111111
|
|
12
|
+
* input: { product_id: "p1", qty: 3 }
|
|
13
|
+
* expect:
|
|
14
|
+
* success: true
|
|
15
|
+
* response:
|
|
16
|
+
* ok: true
|
|
17
|
+
* order_id: { matches: uuid }
|
|
18
|
+
* db_queries:
|
|
19
|
+
* - sql: "SELECT COUNT(*)::int FROM orders WHERE product_id='p1'"
|
|
20
|
+
* equals: 1
|
|
21
|
+
* teardown_sql: DELETE FROM orders WHERE product_id='p1'; DELETE FROM products WHERE id='p1';
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFile, readdir } from "fs/promises"
|
|
25
|
+
import { join, resolve as resolvePath } from "path"
|
|
26
|
+
import YAML from "yaml"
|
|
27
|
+
import { proxyToolCall } from "../proxy.js"
|
|
28
|
+
import { readLocalConfig } from "./planner.js"
|
|
29
|
+
|
|
30
|
+
// ─── Test file discovery ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function findTestFiles(testsDir) {
|
|
33
|
+
const out = []
|
|
34
|
+
async function walk(dir) {
|
|
35
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
const full = join(dir, e.name)
|
|
38
|
+
if (e.isDirectory()) await walk(full)
|
|
39
|
+
else if (e.isFile() && (e.name.endsWith(".test.yaml") || e.name.endsWith(".test.yml"))) out.push(full)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await walk(testsDir)
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── SQL helpers ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
async function execSql(projectId, sql) {
|
|
49
|
+
const res = await proxyToolCall("execute_sql", { project_id: projectId, sql })
|
|
50
|
+
if (res?.error) throw new Error(res.error)
|
|
51
|
+
return res
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function firstCellOf(res) {
|
|
55
|
+
const row = res?.rows?.[0]
|
|
56
|
+
if (!row) return undefined
|
|
57
|
+
const k = Object.keys(row)[0]
|
|
58
|
+
return k ? row[k] : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve an endpoint name to its UUID via system.endpoints (the remote
|
|
62
|
+
* test_workflow tool only accepts endpoint_id, not endpoint_name). */
|
|
63
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
64
|
+
async function resolveEndpointId(projectId, ref, cache) {
|
|
65
|
+
if (UUID_RE.test(ref)) return ref
|
|
66
|
+
if (cache.has(ref)) return cache.get(ref)
|
|
67
|
+
const safeName = String(ref).replace(/'/g, "''")
|
|
68
|
+
const res = await execSql(projectId,
|
|
69
|
+
`SELECT id FROM system.endpoints WHERE name = '${safeName}' LIMIT 1`)
|
|
70
|
+
const id = firstCellOf(res)
|
|
71
|
+
if (!id) throw new Error(`Endpoint '${ref}' not found (looked up in system.endpoints by name).`)
|
|
72
|
+
cache.set(ref, id)
|
|
73
|
+
return id
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Assertion matchers ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deep match `actual` against `expected`. Expected can contain meta-matchers
|
|
80
|
+
* via special objects with keys `matches`, `equals`, `contains`, `type`,
|
|
81
|
+
* `exists`, `gte`, `lte`. Returns { ok, errors: [] }.
|
|
82
|
+
*/
|
|
83
|
+
function matchValue(actual, expected, path = "") {
|
|
84
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected)) {
|
|
85
|
+
const metaKeys = new Set(["matches", "equals", "contains", "type", "exists", "gte", "lte"])
|
|
86
|
+
const keys = Object.keys(expected)
|
|
87
|
+
if (keys.length && keys.every(k => metaKeys.has(k))) return matchMeta(actual, expected, path)
|
|
88
|
+
|
|
89
|
+
if (actual == null || typeof actual !== "object") {
|
|
90
|
+
return { ok: false, errors: [`${path || "(root)"}: expected object, got ${JSON.stringify(actual)}`] }
|
|
91
|
+
}
|
|
92
|
+
const errors = []
|
|
93
|
+
for (const k of keys) {
|
|
94
|
+
const r = matchValue(actual[k], expected[k], path ? `${path}.${k}` : k)
|
|
95
|
+
if (!r.ok) errors.push(...r.errors)
|
|
96
|
+
}
|
|
97
|
+
return errors.length ? { ok: false, errors } : { ok: true }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (Array.isArray(expected)) {
|
|
101
|
+
if (!Array.isArray(actual)) return { ok: false, errors: [`${path}: expected array, got ${typeof actual}`] }
|
|
102
|
+
if (actual.length !== expected.length) return { ok: false, errors: [`${path}: length ${expected.length} expected, got ${actual.length}`] }
|
|
103
|
+
const errors = []
|
|
104
|
+
expected.forEach((item, i) => {
|
|
105
|
+
const r = matchValue(actual[i], item, `${path}[${i}]`)
|
|
106
|
+
if (!r.ok) errors.push(...r.errors)
|
|
107
|
+
})
|
|
108
|
+
return errors.length ? { ok: false, errors } : { ok: true }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (actual !== expected) {
|
|
112
|
+
return { ok: false, errors: [`${path || "(root)"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`] }
|
|
113
|
+
}
|
|
114
|
+
return { ok: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchMeta(actual, meta, path) {
|
|
118
|
+
const errs = []
|
|
119
|
+
if ("equals" in meta && actual !== meta.equals) {
|
|
120
|
+
errs.push(`${path}: expected ${JSON.stringify(meta.equals)}, got ${JSON.stringify(actual)}`)
|
|
121
|
+
}
|
|
122
|
+
if ("matches" in meta) {
|
|
123
|
+
const m = meta.matches
|
|
124
|
+
const str = String(actual ?? "")
|
|
125
|
+
if (m === "uuid") {
|
|
126
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str)) {
|
|
127
|
+
errs.push(`${path}: expected UUID, got ${JSON.stringify(actual)}`)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
try {
|
|
131
|
+
if (!new RegExp(m).test(str)) errs.push(`${path}: did not match /${m}/, got ${JSON.stringify(actual)}`)
|
|
132
|
+
} catch {
|
|
133
|
+
errs.push(`${path}: invalid regex ${JSON.stringify(m)}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if ("contains" in meta) {
|
|
138
|
+
if (typeof actual !== "string" || !actual.includes(String(meta.contains))) {
|
|
139
|
+
errs.push(`${path}: expected to contain ${JSON.stringify(meta.contains)}, got ${JSON.stringify(actual)}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if ("type" in meta && typeof actual !== meta.type) {
|
|
143
|
+
errs.push(`${path}: expected type ${meta.type}, got ${typeof actual}`)
|
|
144
|
+
}
|
|
145
|
+
if ("exists" in meta) {
|
|
146
|
+
const exists = actual !== undefined && actual !== null
|
|
147
|
+
if (meta.exists !== exists) errs.push(`${path}: expected exists=${meta.exists}, got ${exists}`)
|
|
148
|
+
}
|
|
149
|
+
if ("gte" in meta && !(Number(actual) >= Number(meta.gte))) {
|
|
150
|
+
errs.push(`${path}: expected >= ${meta.gte}, got ${actual}`)
|
|
151
|
+
}
|
|
152
|
+
if ("lte" in meta && !(Number(actual) <= Number(meta.lte))) {
|
|
153
|
+
errs.push(`${path}: expected <= ${meta.lte}, got ${actual}`)
|
|
154
|
+
}
|
|
155
|
+
return errs.length ? { ok: false, errors: errs } : { ok: true }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Single test runner ─────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async function runSingleTest(test, fileCtx) {
|
|
161
|
+
const result = { name: test.name || "(unnamed)", status: "pass", errors: [] }
|
|
162
|
+
|
|
163
|
+
// Setup
|
|
164
|
+
if (test.setup_sql) {
|
|
165
|
+
try { await execSql(fileCtx.projectId, test.setup_sql) }
|
|
166
|
+
catch (e) { return { ...result, status: "error", phase: "setup", errors: [`setup_sql failed: ${e.message}`] } }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Resolve endpoint → endpoint_id (remote only accepts UUIDs)
|
|
171
|
+
const endpointRef = test.endpoint || test.endpoint_id || fileCtx.endpoint
|
|
172
|
+
if (!endpointRef) {
|
|
173
|
+
return { ...result, status: "error", errors: ["No endpoint specified. Set `endpoint` at file root or test level."] }
|
|
174
|
+
}
|
|
175
|
+
const endpointId = await resolveEndpointId(fileCtx.projectId, endpointRef, fileCtx.endpointCache)
|
|
176
|
+
|
|
177
|
+
const execArgs = {
|
|
178
|
+
project_id: fileCtx.projectId,
|
|
179
|
+
endpoint_id: endpointId,
|
|
180
|
+
data: test.input || {},
|
|
181
|
+
trace_mode: "minimal", // keep tests fast; detail on failure via dypai_trace
|
|
182
|
+
}
|
|
183
|
+
if (test.as_user) execArgs.impersonated_user_id = test.as_user
|
|
184
|
+
|
|
185
|
+
const runResponse = await proxyToolCall("test_workflow", execArgs)
|
|
186
|
+
|
|
187
|
+
// Normalize what counts as the workflow "result body" (vary by engine version)
|
|
188
|
+
const body = runResponse?.result ?? runResponse?.data ?? runResponse?.output ?? runResponse
|
|
189
|
+
const trace = runResponse?.trace
|
|
190
|
+
|
|
191
|
+
// Assertions
|
|
192
|
+
const expect = test.expect || {}
|
|
193
|
+
|
|
194
|
+
// expect.success — did the workflow complete?
|
|
195
|
+
if ("success" in expect) {
|
|
196
|
+
const status = trace?.status ?? trace?.workflow?.status
|
|
197
|
+
const actualSuccess = status
|
|
198
|
+
? status === "completed"
|
|
199
|
+
: !runResponse?.error // fallback: presence of error field
|
|
200
|
+
if (actualSuccess !== expect.success) {
|
|
201
|
+
result.errors.push(`expected success=${expect.success}, got ${actualSuccess}`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// expect.response — match body
|
|
206
|
+
if (expect.response !== undefined) {
|
|
207
|
+
const r = matchValue(body, expect.response, "response")
|
|
208
|
+
if (!r.ok) result.errors.push(...r.errors)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// expect.failed_at — name of the node that should have failed
|
|
212
|
+
if (expect.failed_at) {
|
|
213
|
+
const failedAt = trace?.failed_at ?? trace?.workflow?.failed_at
|
|
214
|
+
if (failedAt !== expect.failed_at) {
|
|
215
|
+
result.errors.push(`expected failed_at='${expect.failed_at}', got ${JSON.stringify(failedAt)}`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// expect.db_queries — each runs a SELECT and compares first cell or full rows
|
|
220
|
+
if (Array.isArray(expect.db_queries)) {
|
|
221
|
+
for (const q of expect.db_queries) {
|
|
222
|
+
try {
|
|
223
|
+
const res = await execSql(fileCtx.projectId, q.sql)
|
|
224
|
+
const first = firstCellOf(res)
|
|
225
|
+
if ("equals" in q && first !== q.equals) {
|
|
226
|
+
result.errors.push(`db_query "${q.sql.slice(0, 50)}": expected ${q.equals}, got ${JSON.stringify(first)}`)
|
|
227
|
+
}
|
|
228
|
+
if ("gte" in q && !(Number(first) >= Number(q.gte))) {
|
|
229
|
+
result.errors.push(`db_query: expected >= ${q.gte}, got ${first}`)
|
|
230
|
+
}
|
|
231
|
+
if ("lte" in q && !(Number(first) <= Number(q.lte))) {
|
|
232
|
+
result.errors.push(`db_query: expected <= ${q.lte}, got ${first}`)
|
|
233
|
+
}
|
|
234
|
+
if (q.match) {
|
|
235
|
+
const r = matchValue(res?.rows, q.match, "db_rows")
|
|
236
|
+
if (!r.ok) result.errors.push(...r.errors)
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
239
|
+
result.errors.push(`db_query execution failed: ${e.message}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
result.errors.push(`execution error: ${e.message}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Teardown — always try, even if the test failed
|
|
248
|
+
if (test.teardown_sql) {
|
|
249
|
+
try { await execSql(fileCtx.projectId, test.teardown_sql) }
|
|
250
|
+
catch (e) { result.errors.push(`teardown_sql failed: ${e.message}`) }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (result.errors.length) result.status = "fail"
|
|
254
|
+
else delete result.errors
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Tool ───────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export const dypaiTestTool = {
|
|
261
|
+
name: "dypai_test",
|
|
262
|
+
description:
|
|
263
|
+
"Run YAML-defined tests from dypai/tests/*.test.yaml. Each test does: setup_sql → test_workflow (with impersonation) → response assertions → db_queries assertions → teardown_sql. " +
|
|
264
|
+
"Uses existing remote tools (execute_sql, test_workflow) — no engine changes needed. " +
|
|
265
|
+
"Pass `only` to run a subset (substring match on test name).",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: "object",
|
|
268
|
+
properties: {
|
|
269
|
+
project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml." },
|
|
270
|
+
root_dir: { type: "string", default: "./dypai" },
|
|
271
|
+
only: { type: "string", description: "Only run tests whose name includes this substring." },
|
|
272
|
+
file: { type: "string", description: "Relative path to a single test file under dypai/tests/." },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
async execute({ project_id, root_dir = "./dypai", only, file } = {}) {
|
|
276
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
277
|
+
const config = await readLocalConfig(rootDir)
|
|
278
|
+
const projectId = project_id || config?.project_id
|
|
279
|
+
if (!projectId) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly.",
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const testsDir = join(rootDir, "tests")
|
|
287
|
+
let files = []
|
|
288
|
+
if (file) {
|
|
289
|
+
files = [file.startsWith("/") ? file : join(testsDir, file)]
|
|
290
|
+
} else {
|
|
291
|
+
files = await findTestFiles(testsDir)
|
|
292
|
+
}
|
|
293
|
+
if (!files.length) {
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
total: 0, pass: 0, fail: 0, error: 0,
|
|
297
|
+
results: [],
|
|
298
|
+
hint: "No test files found. Create dypai/tests/<endpoint>.test.yaml — see README for format.",
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const allResults = []
|
|
303
|
+
const parseErrors = []
|
|
304
|
+
for (const f of files) {
|
|
305
|
+
let doc
|
|
306
|
+
try {
|
|
307
|
+
const raw = await readFile(f, "utf8")
|
|
308
|
+
doc = YAML.parse(raw) || {}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
parseErrors.push({ file: f, error: e.message })
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
const tests = Array.isArray(doc) ? doc : (doc.tests || [])
|
|
314
|
+
const fileCtx = {
|
|
315
|
+
projectId,
|
|
316
|
+
endpoint: doc.endpoint || doc.endpoint_id,
|
|
317
|
+
rootDir,
|
|
318
|
+
endpointCache: new Map(),
|
|
319
|
+
}
|
|
320
|
+
for (const t of tests) {
|
|
321
|
+
if (only && !(t.name || "").toLowerCase().includes(only.toLowerCase())) continue
|
|
322
|
+
const r = await runSingleTest(t, fileCtx)
|
|
323
|
+
r.file = f.replace(rootDir + "/", "")
|
|
324
|
+
allResults.push(r)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const pass = allResults.filter(r => r.status === "pass").length
|
|
329
|
+
const fail = allResults.filter(r => r.status === "fail").length
|
|
330
|
+
const error = allResults.filter(r => r.status === "error").length
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
success: fail === 0 && error === 0 && parseErrors.length === 0,
|
|
334
|
+
total: allResults.length,
|
|
335
|
+
pass, fail, error,
|
|
336
|
+
results: allResults,
|
|
337
|
+
parse_errors: parseErrors.length ? parseErrors : undefined,
|
|
338
|
+
hint: (fail + error) > 0
|
|
339
|
+
? `${fail} test(s) failed, ${error} errored. For failing ones, use dypai_trace on the reported execution_id for deeper inspection.`
|
|
340
|
+
: undefined,
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
}
|