@gxp-dev/tools 2.0.70 → 2.0.72
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/README.md +108 -81
- package/bin/lib/cli.js +18 -0
- package/bin/lib/commands/index.js +2 -0
- package/bin/lib/commands/init.js +23 -0
- package/bin/lib/commands/lint.js +77 -0
- package/bin/lib/constants.js +12 -0
- package/bin/lib/lint/formatter.js +91 -0
- package/bin/lib/lint/index.js +284 -0
- package/bin/lib/lint/schemas/app-manifest.schema.json +124 -0
- package/bin/lib/lint/schemas/card.schema.json +165 -0
- package/bin/lib/lint/schemas/common.schema.json +62 -0
- package/bin/lib/lint/schemas/configuration.schema.json +19 -0
- package/bin/lib/lint/schemas/field.schema.json +230 -0
- package/mcp/gxp-api-server.js +56 -127
- package/mcp/lib/api-tools.js +456 -0
- package/mcp/lib/config-ops.js +234 -0
- package/mcp/lib/config-tools.js +549 -0
- package/mcp/lib/docs-tools.js +142 -0
- package/mcp/lib/docs.js +263 -0
- package/mcp/lib/specs.js +135 -0
- package/mcp/lib/test-tools.js +358 -0
- package/package.json +3 -1
- package/runtime/stores/gxpPortalConfigStore.js +0 -4
- package/runtime/vite.config.js +5 -3
- package/template/.prettierrc +10 -0
- package/template/README.md +205 -240
- package/template/app-instructions.md +91 -0
- package/template/eslint.config.js +32 -0
- package/template/githooks/pre-commit +37 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for plugin testing:
|
|
3
|
+
*
|
|
4
|
+
* - test_scaffold_component_test : writes a Vitest + Vue Test Utils file
|
|
5
|
+
* for a given Vue component, with render/props/events placeholders.
|
|
6
|
+
*
|
|
7
|
+
* - test_api_route : resolves an OpenAPI operationId to method+path,
|
|
8
|
+
* substitutes path parameters, and hits the local mock API (default
|
|
9
|
+
* http://localhost:3069/api) with optional query and body, returning
|
|
10
|
+
* status, headers, and response body.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs")
|
|
14
|
+
const path = require("path")
|
|
15
|
+
|
|
16
|
+
const { fetchSpec } = require("./specs")
|
|
17
|
+
|
|
18
|
+
function contentResult(obj) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveProjectPath(p) {
|
|
25
|
+
if (!p) throw new Error("`path` argument is required")
|
|
26
|
+
return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ------------------------- scaffold_component_test ----------------------- */
|
|
30
|
+
|
|
31
|
+
function componentNameFromFile(componentPath) {
|
|
32
|
+
const base = path.basename(componentPath).replace(/\.vue$/i, "")
|
|
33
|
+
return base.replace(/[^A-Za-z0-9_$]/g, "_") || "Component"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultTestPathFor(componentAbsPath, projectRoot) {
|
|
37
|
+
const rel = path.relative(projectRoot, componentAbsPath)
|
|
38
|
+
const base = path.basename(rel, ".vue")
|
|
39
|
+
// Siblings to the project root: tests/<Component>.test.js
|
|
40
|
+
return path.join(projectRoot, "tests", `${base}.test.js`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderScaffold({ componentName, importPath }) {
|
|
44
|
+
return `import { describe, it, expect } from "vitest"
|
|
45
|
+
import { mount } from "@vue/test-utils"
|
|
46
|
+
import ${componentName} from "${importPath}"
|
|
47
|
+
|
|
48
|
+
describe("${componentName}", () => {
|
|
49
|
+
it("renders the component", () => {
|
|
50
|
+
const wrapper = mount(${componentName})
|
|
51
|
+
expect(wrapper.exists()).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it.todo("renders expected headings/labels")
|
|
55
|
+
|
|
56
|
+
it.todo("accepts props")
|
|
57
|
+
|
|
58
|
+
it.todo("emits events on interaction")
|
|
59
|
+
})
|
|
60
|
+
`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toImportPath(absComponent, absTestFile, projectRoot) {
|
|
64
|
+
// Prefer the "@/…" alias when the component lives under <projectRoot>/src.
|
|
65
|
+
const srcDir = path.join(projectRoot, "src")
|
|
66
|
+
if (absComponent.startsWith(srcDir + path.sep) || absComponent === srcDir) {
|
|
67
|
+
const rel = path.relative(srcDir, absComponent).replace(/\\/g, "/")
|
|
68
|
+
return `@/${rel}`
|
|
69
|
+
}
|
|
70
|
+
// Fall back to a relative path from the test file.
|
|
71
|
+
const rel = path.relative(path.dirname(absTestFile), absComponent)
|
|
72
|
+
const norm = rel.replace(/\\/g, "/")
|
|
73
|
+
return norm.startsWith(".") ? norm : `./${norm}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scaffoldComponentTest({
|
|
77
|
+
componentPath,
|
|
78
|
+
testPath,
|
|
79
|
+
componentName,
|
|
80
|
+
overwrite = false,
|
|
81
|
+
projectRoot,
|
|
82
|
+
}) {
|
|
83
|
+
const projRoot = projectRoot || process.cwd()
|
|
84
|
+
const absComponent = resolveProjectPath(componentPath)
|
|
85
|
+
if (!fs.existsSync(absComponent)) {
|
|
86
|
+
return { ok: false, error: `Component not found: ${absComponent}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const absTest = testPath
|
|
90
|
+
? resolveProjectPath(testPath)
|
|
91
|
+
: defaultTestPathFor(absComponent, projRoot)
|
|
92
|
+
const name = componentName || componentNameFromFile(absComponent)
|
|
93
|
+
const importPath = toImportPath(absComponent, absTest, projRoot)
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(absTest) && !overwrite) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `Test already exists at ${absTest}. Pass overwrite: true to replace.`,
|
|
99
|
+
test_path: absTest,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fs.mkdirSync(path.dirname(absTest), { recursive: true })
|
|
104
|
+
fs.writeFileSync(
|
|
105
|
+
absTest,
|
|
106
|
+
renderScaffold({ componentName: name, importPath }),
|
|
107
|
+
"utf-8",
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
test_path: absTest,
|
|
112
|
+
component_name: name,
|
|
113
|
+
import_path: importPath,
|
|
114
|
+
run_with: "npx vitest run " + path.relative(projRoot, absTest),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ------------------------------ test_api_route --------------------------- */
|
|
119
|
+
|
|
120
|
+
function substitutePathParams(rawPath, params = {}) {
|
|
121
|
+
// Replace /path/{name} and /path/:name with the given values.
|
|
122
|
+
const missing = []
|
|
123
|
+
let out = rawPath.replace(/\{([^}]+)\}/g, (_m, name) => {
|
|
124
|
+
if (params[name] === undefined || params[name] === null) {
|
|
125
|
+
missing.push(name)
|
|
126
|
+
return `{${name}}`
|
|
127
|
+
}
|
|
128
|
+
return encodeURIComponent(String(params[name]))
|
|
129
|
+
})
|
|
130
|
+
out = out.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name) => {
|
|
131
|
+
if (params[name] === undefined || params[name] === null) {
|
|
132
|
+
missing.push(name)
|
|
133
|
+
return `:${name}`
|
|
134
|
+
}
|
|
135
|
+
return encodeURIComponent(String(params[name]))
|
|
136
|
+
})
|
|
137
|
+
return { path: out, missing }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildQueryString(query) {
|
|
141
|
+
if (!query || typeof query !== "object") return ""
|
|
142
|
+
const params = new URLSearchParams()
|
|
143
|
+
for (const [k, v] of Object.entries(query)) {
|
|
144
|
+
if (v === undefined || v === null) continue
|
|
145
|
+
if (Array.isArray(v)) {
|
|
146
|
+
for (const item of v) params.append(k, String(item))
|
|
147
|
+
} else {
|
|
148
|
+
params.set(k, String(v))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const s = params.toString()
|
|
152
|
+
return s ? `?${s}` : ""
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function findOperation(spec, operationId) {
|
|
156
|
+
if (!spec?.paths) return null
|
|
157
|
+
for (const [p, methods] of Object.entries(spec.paths)) {
|
|
158
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
159
|
+
if (typeof op === "object" && op?.operationId === operationId) {
|
|
160
|
+
return { path: p, method: method.toUpperCase(), op }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function testApiRoute({
|
|
168
|
+
operationId,
|
|
169
|
+
pathParams,
|
|
170
|
+
query,
|
|
171
|
+
body,
|
|
172
|
+
headers,
|
|
173
|
+
baseUrl,
|
|
174
|
+
timeoutMs,
|
|
175
|
+
}) {
|
|
176
|
+
const spec = await fetchSpec("openapi")
|
|
177
|
+
const found = findOperation(spec, operationId)
|
|
178
|
+
if (!found) {
|
|
179
|
+
return { ok: false, error: `Operation not found: ${operationId}` }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { path: rawPath, method } = found
|
|
183
|
+
const { path: resolvedPath, missing } = substitutePathParams(
|
|
184
|
+
rawPath,
|
|
185
|
+
pathParams,
|
|
186
|
+
)
|
|
187
|
+
if (missing.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: `Missing required path parameters: ${missing.join(", ")}`,
|
|
191
|
+
required_parameters: missing,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const base = (baseUrl || "http://localhost:3069/api").replace(/\/+$/, "")
|
|
196
|
+
const url = `${base}${resolvedPath}${buildQueryString(query)}`
|
|
197
|
+
|
|
198
|
+
const controller = new AbortController()
|
|
199
|
+
const timer = setTimeout(
|
|
200
|
+
() => controller.abort(),
|
|
201
|
+
Math.max(1000, timeoutMs ?? 10000),
|
|
202
|
+
)
|
|
203
|
+
const t0 = Date.now()
|
|
204
|
+
try {
|
|
205
|
+
const hasBody =
|
|
206
|
+
body !== undefined && body !== null && !["GET", "HEAD"].includes(method)
|
|
207
|
+
const res = await fetch(url, {
|
|
208
|
+
method,
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
headers: {
|
|
211
|
+
Accept: "application/json",
|
|
212
|
+
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
213
|
+
...(headers || {}),
|
|
214
|
+
},
|
|
215
|
+
body: hasBody ? JSON.stringify(body) : undefined,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const duration_ms = Date.now() - t0
|
|
219
|
+
const respHeaders = {}
|
|
220
|
+
res.headers.forEach((v, k) => {
|
|
221
|
+
respHeaders[k] = v
|
|
222
|
+
})
|
|
223
|
+
const raw = await res.text()
|
|
224
|
+
let parsedBody = raw
|
|
225
|
+
try {
|
|
226
|
+
parsedBody = JSON.parse(raw)
|
|
227
|
+
} catch {
|
|
228
|
+
// keep as text
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
ok: res.ok,
|
|
233
|
+
request: {
|
|
234
|
+
method,
|
|
235
|
+
url,
|
|
236
|
+
},
|
|
237
|
+
response: {
|
|
238
|
+
status: res.status,
|
|
239
|
+
statusText: res.statusText,
|
|
240
|
+
headers: respHeaders,
|
|
241
|
+
body: parsedBody,
|
|
242
|
+
},
|
|
243
|
+
duration_ms,
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const duration_ms = Date.now() - t0
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
request: { method, url },
|
|
250
|
+
error: err.name === "AbortError" ? "Request timed out" : err.message,
|
|
251
|
+
duration_ms,
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ------------------------------- tool schemas ----------------------------- */
|
|
259
|
+
|
|
260
|
+
const TEST_TOOLS = [
|
|
261
|
+
{
|
|
262
|
+
name: "test_scaffold_component_test",
|
|
263
|
+
description:
|
|
264
|
+
"Create a Vitest + Vue Test Utils test file for a given Vue component. Picks an import path via the @/ alias when the component lives under src/, else falls back to a relative path. Refuses to overwrite unless overwrite=true.",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: "object",
|
|
267
|
+
properties: {
|
|
268
|
+
componentPath: {
|
|
269
|
+
type: "string",
|
|
270
|
+
description:
|
|
271
|
+
"Absolute or project-relative path to the .vue file to test.",
|
|
272
|
+
},
|
|
273
|
+
testPath: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description:
|
|
276
|
+
"Optional override for where to write the test file. Default: tests/<ComponentName>.test.js at the project root.",
|
|
277
|
+
},
|
|
278
|
+
componentName: {
|
|
279
|
+
type: "string",
|
|
280
|
+
description:
|
|
281
|
+
"Optional override for the identifier used in the test. Default: the component filename without extension.",
|
|
282
|
+
},
|
|
283
|
+
overwrite: { type: "boolean", default: false },
|
|
284
|
+
},
|
|
285
|
+
required: ["componentPath"],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "test_api_route",
|
|
290
|
+
description:
|
|
291
|
+
"Exercise a single API endpoint by operationId. Resolves the OpenAPI spec to method+path, substitutes path params, and issues a request. Defaults to the local mock API at http://localhost:3069/api — override via baseUrl to hit staging/develop. Returns status, headers, parsed body, and duration.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
operationId: {
|
|
296
|
+
type: "string",
|
|
297
|
+
description: "OpenAPI operationId (e.g. 'attendees.index').",
|
|
298
|
+
},
|
|
299
|
+
pathParams: {
|
|
300
|
+
type: "object",
|
|
301
|
+
description:
|
|
302
|
+
"Values for {param} / :param segments in the path, keyed by parameter name.",
|
|
303
|
+
},
|
|
304
|
+
query: {
|
|
305
|
+
type: "object",
|
|
306
|
+
description:
|
|
307
|
+
"Query-string values keyed by parameter name. Arrays send repeated keys.",
|
|
308
|
+
},
|
|
309
|
+
body: {
|
|
310
|
+
description: "Request body. Sent as JSON for non-GET/HEAD methods.",
|
|
311
|
+
},
|
|
312
|
+
headers: {
|
|
313
|
+
type: "object",
|
|
314
|
+
description: "Additional headers to include.",
|
|
315
|
+
},
|
|
316
|
+
baseUrl: {
|
|
317
|
+
type: "string",
|
|
318
|
+
description:
|
|
319
|
+
"Override the base URL. Default: http://localhost:3069/api (the toolkit's mock API mount).",
|
|
320
|
+
},
|
|
321
|
+
timeoutMs: {
|
|
322
|
+
type: "integer",
|
|
323
|
+
description: "Abort after N ms. Default 10000.",
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
required: ["operationId"],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
async function handleTestToolCall(name, args = {}) {
|
|
332
|
+
switch (name) {
|
|
333
|
+
case "test_scaffold_component_test":
|
|
334
|
+
return contentResult(scaffoldComponentTest(args))
|
|
335
|
+
|
|
336
|
+
case "test_api_route":
|
|
337
|
+
return contentResult(await testApiRoute(args))
|
|
338
|
+
|
|
339
|
+
default:
|
|
340
|
+
throw new Error(`Unknown test tool: ${name}`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isTestTool(name) {
|
|
345
|
+
return TEST_TOOLS.some((t) => t.name === name)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
TEST_TOOLS,
|
|
350
|
+
handleTestToolCall,
|
|
351
|
+
isTestTool,
|
|
352
|
+
// Exported for testing
|
|
353
|
+
scaffoldComponentTest,
|
|
354
|
+
substitutePathParams,
|
|
355
|
+
buildQueryString,
|
|
356
|
+
findOperation,
|
|
357
|
+
testApiRoute,
|
|
358
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gxp-dev/tools",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.72",
|
|
4
4
|
"description": "Dev tools to create platform plugins",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"publishConfig": {
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
"@gramercytech/gx-componentkit": "^1.0.23",
|
|
61
61
|
"@vitejs/plugin-vue": "^6.0.6",
|
|
62
62
|
"adm-zip": "^0.5.17",
|
|
63
|
+
"ajv": "^8.18.0",
|
|
64
|
+
"ajv-formats": "^3.0.1",
|
|
63
65
|
"chrome-launcher": "^1.2.1",
|
|
64
66
|
"concurrently": "^9.2.1",
|
|
65
67
|
"cors": "^2.8.6",
|
|
@@ -778,10 +778,6 @@ export const useGxpStore = defineStore("gxp-portal-app", () => {
|
|
|
778
778
|
findDependency,
|
|
779
779
|
|
|
780
780
|
// Update methods (for DevTools and programmatic updates)
|
|
781
|
-
updateString,
|
|
782
|
-
updateSetting,
|
|
783
|
-
updateAsset,
|
|
784
|
-
updateState,
|
|
785
781
|
addDevAsset,
|
|
786
782
|
|
|
787
783
|
// Socket methods
|
package/runtime/vite.config.js
CHANGED
|
@@ -320,9 +320,11 @@ export default defineConfig(async (ctx) => {
|
|
|
320
320
|
"@/stores/gxpPortalConfigStore":
|
|
321
321
|
"(window.useGxpStore || (() => { console.warn('useGxpStore not found on window, using fallback'); return {}; }))",
|
|
322
322
|
},
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
// No `include` filter — rewrite `from "vue"` / `from "pinia"`
|
|
324
|
+
// in every module of the final bundle, including transitive deps
|
|
325
|
+
// from node_modules (component libraries, etc.). Without this,
|
|
326
|
+
// deps' internal `import { h } from "vue"` leak through as bare
|
|
327
|
+
// specifiers and crash at runtime on the platform.
|
|
326
328
|
),
|
|
327
329
|
// Custom request logging and CORS plugin
|
|
328
330
|
{
|