devglide 0.1.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/README.md +338 -0
- package/bin/claude-md-template.js +94 -0
- package/bin/devglide.js +387 -0
- package/package.json +85 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/apps/coder/.turbo/turbo-lint.log +5 -0
- package/src/apps/coder/package.json +16 -0
- package/src/apps/coder/public/favicon.svg +7 -0
- package/src/apps/coder/public/page.css +275 -0
- package/src/apps/coder/public/page.js +528 -0
- package/src/apps/coder/server.js +3 -0
- package/src/apps/documentation/public/page.css +597 -0
- package/src/apps/documentation/public/page.js +609 -0
- package/src/apps/kanban/.turbo/turbo-lint.log +97 -0
- package/src/apps/kanban/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/kanban/package.json +32 -0
- package/src/apps/kanban/public/favicon.svg +7 -0
- package/src/apps/kanban/public/page.css +1010 -0
- package/src/apps/kanban/public/page.js +1730 -0
- package/src/apps/kanban/public/vendor/marked.min.js +6 -0
- package/src/apps/kanban/public/vendor/sortable.min.js +2 -0
- package/src/apps/kanban/src/db.ts +319 -0
- package/src/apps/kanban/src/index.ts +14 -0
- package/src/apps/kanban/src/mcp-helpers.test.ts +88 -0
- package/src/apps/kanban/src/mcp-helpers.ts +60 -0
- package/src/apps/kanban/src/mcp.ts +59 -0
- package/src/apps/kanban/src/routes/attachments.ts +161 -0
- package/src/apps/kanban/src/routes/features.ts +233 -0
- package/src/apps/kanban/src/routes/issues.ts +373 -0
- package/src/apps/kanban/src/tools/feature-tools.ts +164 -0
- package/src/apps/kanban/src/tools/item-tools.ts +307 -0
- package/src/apps/kanban/src/tools/versioned-entry-tools.ts +72 -0
- package/src/apps/kanban/tsconfig.check.json +9 -0
- package/src/apps/kanban/tsconfig.json +9 -0
- package/src/apps/keymap/.turbo/turbo-lint.log +5 -0
- package/src/apps/keymap/package.json +16 -0
- package/src/apps/keymap/public/page.css +275 -0
- package/src/apps/keymap/public/page.js +294 -0
- package/src/apps/keymap/server.js +25 -0
- package/src/apps/log/.turbo/turbo-build.log +5 -0
- package/src/apps/log/.turbo/turbo-lint.log +45 -0
- package/src/apps/log/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/log/node_modules/.bin/tsc +21 -0
- package/src/apps/log/node_modules/.bin/tsserver +21 -0
- package/src/apps/log/node_modules/.bin/tsx +21 -0
- package/src/apps/log/package.json +36 -0
- package/src/apps/log/public/console-sniffer.js +221 -0
- package/src/apps/log/public/favicon.svg +7 -0
- package/src/apps/log/public/page.css +322 -0
- package/src/apps/log/public/page.js +463 -0
- package/src/apps/log/src/index.ts +9 -0
- package/src/apps/log/src/mcp.ts +122 -0
- package/src/apps/log/src/routes/log.ts +333 -0
- package/src/apps/log/src/routes/status.ts +25 -0
- package/src/apps/log/src/server-sniffer.ts +118 -0
- package/src/apps/log/src/services/file-patterns.ts +39 -0
- package/src/apps/log/src/services/file-tailer.ts +228 -0
- package/src/apps/log/src/services/line-parser.ts +94 -0
- package/src/apps/log/src/services/log-writer.ts +39 -0
- package/src/apps/log/tsconfig.json +8 -0
- package/src/apps/prompts/.turbo/turbo-build.log +5 -0
- package/src/apps/prompts/.turbo/turbo-lint.log +24 -0
- package/src/apps/prompts/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/prompts/mcp.ts +175 -0
- package/src/apps/prompts/node_modules/.bin/tsc +21 -0
- package/src/apps/prompts/node_modules/.bin/tsserver +21 -0
- package/src/apps/prompts/node_modules/.bin/tsx +21 -0
- package/src/apps/prompts/package.json +25 -0
- package/src/apps/prompts/public/page.css +315 -0
- package/src/apps/prompts/public/page.js +541 -0
- package/src/apps/prompts/services/prompt-store.ts +212 -0
- package/src/apps/prompts/src/index.ts +9 -0
- package/src/apps/prompts/tsconfig.json +8 -0
- package/src/apps/prompts/types.ts +27 -0
- package/src/apps/shell/.turbo/turbo-build.log +5 -0
- package/src/apps/shell/.turbo/turbo-lint.log +34 -0
- package/src/apps/shell/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/shell/package.json +35 -0
- package/src/apps/shell/public/favicon.svg +7 -0
- package/src/apps/shell/public/page.css +407 -0
- package/src/apps/shell/public/page.js +1577 -0
- package/src/apps/shell/src/index.ts +150 -0
- package/src/apps/shell/src/mcp.ts +398 -0
- package/src/apps/shell/src/shell-types.ts +41 -0
- package/src/apps/shell/tsconfig.json +8 -0
- package/src/apps/test/.turbo/turbo-build.log +5 -0
- package/src/apps/test/.turbo/turbo-lint.log +27 -0
- package/src/apps/test/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/test/node_modules/.bin/tsc +21 -0
- package/src/apps/test/node_modules/.bin/tsserver +21 -0
- package/src/apps/test/node_modules/.bin/tsx +21 -0
- package/src/apps/test/node_modules/.bin/uuid +21 -0
- package/src/apps/test/package.json +35 -0
- package/src/apps/test/public/favicon.svg +7 -0
- package/src/apps/test/public/page.css +499 -0
- package/src/apps/test/public/page.js +417 -0
- package/src/apps/test/public/scenario-runner.js +450 -0
- package/src/apps/test/src/index.ts +9 -0
- package/src/apps/test/src/mcp.ts +192 -0
- package/src/apps/test/src/routes/trigger.ts +285 -0
- package/src/apps/test/src/services/scenario-broadcaster.ts +60 -0
- package/src/apps/test/src/services/scenario-manager.ts +361 -0
- package/src/apps/test/src/services/scenario-store.ts +145 -0
- package/src/apps/test/tsconfig.json +8 -0
- package/src/apps/vocabulary/.turbo/turbo-build.log +5 -0
- package/src/apps/vocabulary/.turbo/turbo-lint.log +25 -0
- package/src/apps/vocabulary/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/vocabulary/mcp.ts +173 -0
- package/src/apps/vocabulary/node_modules/.bin/tsc +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsserver +21 -0
- package/src/apps/vocabulary/node_modules/.bin/tsx +21 -0
- package/src/apps/vocabulary/package.json +25 -0
- package/src/apps/vocabulary/public/page.css +247 -0
- package/src/apps/vocabulary/public/page.js +444 -0
- package/src/apps/vocabulary/services/vocabulary-store.ts +179 -0
- package/src/apps/vocabulary/src/index.ts +10 -0
- package/src/apps/vocabulary/tsconfig.json +8 -0
- package/src/apps/vocabulary/types.ts +22 -0
- package/src/apps/voice/.turbo/turbo-build.log +5 -0
- package/src/apps/voice/.turbo/turbo-lint.log +43 -0
- package/src/apps/voice/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/voice/node_modules/.bin/openai +21 -0
- package/src/apps/voice/node_modules/.bin/tsc +21 -0
- package/src/apps/voice/node_modules/.bin/tsserver +21 -0
- package/src/apps/voice/node_modules/.bin/tsx +21 -0
- package/src/apps/voice/package.json +35 -0
- package/src/apps/voice/public/favicon.svg +7 -0
- package/src/apps/voice/public/page.css +388 -0
- package/src/apps/voice/public/page.js +718 -0
- package/src/apps/voice/src/index.ts +10 -0
- package/src/apps/voice/src/mcp.ts +70 -0
- package/src/apps/voice/src/providers/index.ts +85 -0
- package/src/apps/voice/src/providers/openai-compatible.ts +94 -0
- package/src/apps/voice/src/providers/types.ts +27 -0
- package/src/apps/voice/src/routes/config.ts +118 -0
- package/src/apps/voice/src/routes/transcribe.ts +90 -0
- package/src/apps/voice/src/services/config-store.ts +129 -0
- package/src/apps/voice/src/services/stats.ts +108 -0
- package/src/apps/voice/src/transcribe.ts +11 -0
- package/src/apps/voice/src/utils/mime.ts +16 -0
- package/src/apps/voice/tsconfig.json +8 -0
- package/src/apps/workflow/.turbo/turbo-build.log +5 -0
- package/src/apps/workflow/.turbo/turbo-lint.log +96 -0
- package/src/apps/workflow/.turbo/turbo-typecheck.log +5 -0
- package/src/apps/workflow/engine/executors/decision-executor.ts +87 -0
- package/src/apps/workflow/engine/executors/file-executor.ts +90 -0
- package/src/apps/workflow/engine/executors/git-executor.ts +137 -0
- package/src/apps/workflow/engine/executors/http-executor.ts +65 -0
- package/src/apps/workflow/engine/executors/index.ts +28 -0
- package/src/apps/workflow/engine/executors/kanban-executor.ts +154 -0
- package/src/apps/workflow/engine/executors/llm-executor.ts +46 -0
- package/src/apps/workflow/engine/executors/log-executor.ts +62 -0
- package/src/apps/workflow/engine/executors/loop-executor.ts +14 -0
- package/src/apps/workflow/engine/executors/shell-executor.ts +107 -0
- package/src/apps/workflow/engine/executors/sub-workflow-executor.ts +61 -0
- package/src/apps/workflow/engine/executors/test-executor.ts +73 -0
- package/src/apps/workflow/engine/executors/trigger-executor.ts +39 -0
- package/src/apps/workflow/engine/expression-evaluator.ts +117 -0
- package/src/apps/workflow/engine/graph-runner.ts +438 -0
- package/src/apps/workflow/engine/node-executor.ts +104 -0
- package/src/apps/workflow/engine/node-registry.ts +15 -0
- package/src/apps/workflow/engine/variable-resolver.ts +109 -0
- package/src/apps/workflow/mcp.ts +223 -0
- package/src/apps/workflow/node_modules/.bin/tsc +21 -0
- package/src/apps/workflow/node_modules/.bin/tsserver +21 -0
- package/src/apps/workflow/node_modules/.bin/tsx +21 -0
- package/src/apps/workflow/package.json +25 -0
- package/src/apps/workflow/public/editor/canvas.js +366 -0
- package/src/apps/workflow/public/editor/drag-manager.js +326 -0
- package/src/apps/workflow/public/editor/edge-renderer.js +235 -0
- package/src/apps/workflow/public/editor/history-manager.js +147 -0
- package/src/apps/workflow/public/editor/layout-engine.js +159 -0
- package/src/apps/workflow/public/editor/node-renderer.js +199 -0
- package/src/apps/workflow/public/editor/selection-manager.js +193 -0
- package/src/apps/workflow/public/favicon.svg +7 -0
- package/src/apps/workflow/public/models/node-types.js +300 -0
- package/src/apps/workflow/public/models/workflow-model.js +257 -0
- package/src/apps/workflow/public/page.css +406 -0
- package/src/apps/workflow/public/page.js +658 -0
- package/src/apps/workflow/public/panels/inspector.js +360 -0
- package/src/apps/workflow/public/panels/palette.js +106 -0
- package/src/apps/workflow/public/panels/run-view.js +275 -0
- package/src/apps/workflow/public/panels/toolbar.js +232 -0
- package/src/apps/workflow/public/panels/workflow-list.js +237 -0
- package/src/apps/workflow/public/state/store.js +47 -0
- package/src/apps/workflow/services/custom-node-loader.ts +48 -0
- package/src/apps/workflow/services/legacy-converter.ts +72 -0
- package/src/apps/workflow/services/run-manager.ts +190 -0
- package/src/apps/workflow/services/workflow-store.ts +424 -0
- package/src/apps/workflow/services/workflow-validator.test.ts +103 -0
- package/src/apps/workflow/services/workflow-validator.ts +98 -0
- package/src/apps/workflow/src/index.ts +10 -0
- package/src/apps/workflow/templates/ci-pipeline.json +18 -0
- package/src/apps/workflow/templates/code-review.json +22 -0
- package/src/apps/workflow/templates/kanban-testing.json +24 -0
- package/src/apps/workflow/tsconfig.json +8 -0
- package/src/apps/workflow/types.ts +268 -0
- package/src/packages/auth-middleware.ts +14 -0
- package/src/packages/design-tokens/.turbo/turbo-build.log +10 -0
- package/src/packages/design-tokens/STYLEGUIDE.md +414 -0
- package/src/packages/design-tokens/build.js +413 -0
- package/src/packages/design-tokens/demo/index.html +1367 -0
- package/src/packages/design-tokens/demo/proposition-a.html +717 -0
- package/src/packages/design-tokens/demo/proposition-b.html +1239 -0
- package/src/packages/design-tokens/demo/proposition-c.html +1049 -0
- package/src/packages/design-tokens/dist/tailwind-preset.js +115 -0
- package/src/packages/design-tokens/dist/tokens.css +345 -0
- package/src/packages/design-tokens/dist/tokens.d.ts +229 -0
- package/src/packages/design-tokens/dist/tokens.js +386 -0
- package/src/packages/design-tokens/package.json +25 -0
- package/src/packages/design-tokens/tokens.json +228 -0
- package/src/packages/devtools-middleware.ts +22 -0
- package/src/packages/eslint-config/index.js +63 -0
- package/src/packages/eslint-config/node_modules/.bin/eslint +21 -0
- package/src/packages/eslint-config/package.json +18 -0
- package/src/packages/json-file-store.ts +232 -0
- package/src/packages/mcp-utils/.turbo/turbo-build.log +5 -0
- package/src/packages/mcp-utils/dist/index.d.ts +33 -0
- package/src/packages/mcp-utils/dist/index.d.ts.map +1 -0
- package/src/packages/mcp-utils/dist/index.js +126 -0
- package/src/packages/mcp-utils/dist/index.js.map +1 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsc +21 -0
- package/src/packages/mcp-utils/node_modules/.bin/tsserver +21 -0
- package/src/packages/mcp-utils/package.json +32 -0
- package/src/packages/mcp-utils/src/index.ts +171 -0
- package/src/packages/mcp-utils/tsconfig.json +9 -0
- package/src/packages/paths.ts +18 -0
- package/src/packages/project-context/index.js +55 -0
- package/src/packages/project-context/package.json +13 -0
- package/src/packages/project-store.ts +127 -0
- package/src/packages/server-sniffer.ts +132 -0
- package/src/packages/shared-assets/favicon.svg +7 -0
- package/src/packages/shared-assets/keymap-registry.js +512 -0
- package/src/packages/shared-assets/logo.svg +6 -0
- package/src/packages/shared-assets/package.json +11 -0
- package/src/packages/shared-assets/ui-utils.js +48 -0
- package/src/packages/shared-assets/voice-widget.d.ts +37 -0
- package/src/packages/shared-assets/voice-widget.js +695 -0
- package/src/packages/shared-types/.turbo/turbo-build.log +5 -0
- package/src/packages/shared-types/dist/index.d.ts +39 -0
- package/src/packages/shared-types/dist/index.d.ts.map +1 -0
- package/src/packages/shared-types/node_modules/.bin/tsc +21 -0
- package/src/packages/shared-types/node_modules/.bin/tsserver +21 -0
- package/src/packages/shared-types/package.json +25 -0
- package/src/packages/shared-types/src/index.ts +41 -0
- package/src/packages/shared-types/tsconfig.json +11 -0
- package/src/packages/tsconfig/base.json +15 -0
- package/src/packages/tsconfig/next.json +14 -0
- package/src/packages/tsconfig/node.json +11 -0
- package/src/packages/tsconfig/package.json +10 -0
- package/turbo.json +25 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { Router } from "express";
|
|
3
|
+
import type { Request, Response, Router as RouterType } from "express";
|
|
4
|
+
import { ScenarioManager } from "../services/scenario-manager.js";
|
|
5
|
+
import { ScenarioStore } from "../services/scenario-store.js";
|
|
6
|
+
|
|
7
|
+
export const triggerRouter: RouterType = Router();
|
|
8
|
+
const scenarioManager = ScenarioManager.getInstance();
|
|
9
|
+
|
|
10
|
+
// ── SSE client management ──────────────────────────────────────────────────
|
|
11
|
+
// Map of target key -> Set of SSE response objects
|
|
12
|
+
const sseClients = new Map<string, Set<Response>>();
|
|
13
|
+
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
// Heartbeat timer — send a comment to all connected SSE clients every 30s
|
|
17
|
+
const heartbeatTimer = setInterval(() => {
|
|
18
|
+
for (const clientSet of sseClients.values()) {
|
|
19
|
+
for (const res of clientSet) {
|
|
20
|
+
try {
|
|
21
|
+
res.write(": heartbeat\n\n");
|
|
22
|
+
} catch {
|
|
23
|
+
// client already gone — will be cleaned up on close
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
28
|
+
heartbeatTimer.unref(); // don't keep the process alive just for heartbeats
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Broadcast a scenario to all SSE clients listening on the given target.
|
|
32
|
+
*/
|
|
33
|
+
function broadcastScenario(target: string, scenario: unknown): void {
|
|
34
|
+
const key = scenarioManager.resolveTargetKey(target);
|
|
35
|
+
const clients = sseClients.get(key);
|
|
36
|
+
if (!clients || clients.size === 0) return;
|
|
37
|
+
|
|
38
|
+
const payload = `data: ${JSON.stringify(scenario)}\n\n`;
|
|
39
|
+
for (const res of clients) {
|
|
40
|
+
try {
|
|
41
|
+
res.write(payload);
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore — will be cleaned up on close
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* GET /api/trigger/status — Return pending scenario count.
|
|
50
|
+
* Accepts optional ?projectPath= query param to filter by project.
|
|
51
|
+
* Returns 0 if no project is specified.
|
|
52
|
+
*/
|
|
53
|
+
triggerRouter.get("/status", (req: Request, res: Response) => {
|
|
54
|
+
const projectPath = (req.query.projectPath as string) || null;
|
|
55
|
+
res.json({ pendingScenarios: scenarioManager.getPendingCountForProject(projectPath) });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* GET /api/trigger/commands — Return the command catalog.
|
|
60
|
+
*/
|
|
61
|
+
triggerRouter.get("/commands", (_req: Request, res: Response) => {
|
|
62
|
+
res.json(scenarioManager.getCommandsCatalog());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
interface ScenarioBody {
|
|
66
|
+
name?: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
steps?: Array<{
|
|
69
|
+
command: string;
|
|
70
|
+
selector?: string;
|
|
71
|
+
text?: string;
|
|
72
|
+
value?: string;
|
|
73
|
+
timeout?: number;
|
|
74
|
+
ms?: number;
|
|
75
|
+
clear?: boolean;
|
|
76
|
+
contains?: boolean;
|
|
77
|
+
path?: string;
|
|
78
|
+
}>;
|
|
79
|
+
target?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* POST /api/trigger/scenarios — Submit a scenario for browser execution.
|
|
84
|
+
* If SSE clients are listening for this target, the scenario is broadcast
|
|
85
|
+
* directly rather than being queued (SSE client will dequeue it).
|
|
86
|
+
*/
|
|
87
|
+
triggerRouter.post("/scenarios", (req: Request, res: Response) => {
|
|
88
|
+
const body = req.body as ScenarioBody;
|
|
89
|
+
|
|
90
|
+
if (!body.steps || body.steps.length === 0) {
|
|
91
|
+
res.status(400).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const saved = scenarioManager.submitScenario(body);
|
|
96
|
+
// Broadcast to any SSE clients listening for this target
|
|
97
|
+
if (saved.target) {
|
|
98
|
+
broadcastScenario(saved.target, saved);
|
|
99
|
+
}
|
|
100
|
+
res.status(201).json(saved);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GET /api/trigger/scenarios/stream?target=... — SSE stream for scenario delivery.
|
|
105
|
+
* Sends any pending scenario immediately on connect, then pushes new scenarios
|
|
106
|
+
* as they are submitted. Heartbeat comment every 30 seconds.
|
|
107
|
+
*/
|
|
108
|
+
triggerRouter.get("/scenarios/stream", (req: Request, res: Response) => {
|
|
109
|
+
const target = (req.query.target as string) || "";
|
|
110
|
+
|
|
111
|
+
// Register the target so targetKey resolution works for app-name shortcuts
|
|
112
|
+
scenarioManager.registerTarget(target);
|
|
113
|
+
|
|
114
|
+
// Set up SSE headers
|
|
115
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
116
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
117
|
+
res.setHeader("Connection", "keep-alive");
|
|
118
|
+
res.flushHeaders();
|
|
119
|
+
|
|
120
|
+
// Deliver any already-queued scenario immediately
|
|
121
|
+
const pending = scenarioManager.dequeueScenario(target);
|
|
122
|
+
if (pending) {
|
|
123
|
+
res.write(`data: ${JSON.stringify(pending)}\n\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Register this client for future broadcasts
|
|
127
|
+
const key = scenarioManager.resolveTargetKey(target);
|
|
128
|
+
if (!sseClients.has(key)) {
|
|
129
|
+
sseClients.set(key, new Set());
|
|
130
|
+
}
|
|
131
|
+
sseClients.get(key)!.add(res);
|
|
132
|
+
|
|
133
|
+
// Clean up on disconnect
|
|
134
|
+
req.on("close", () => {
|
|
135
|
+
const clientSet = sseClients.get(key);
|
|
136
|
+
if (clientSet) {
|
|
137
|
+
clientSet.delete(res);
|
|
138
|
+
if (clientSet.size === 0) {
|
|
139
|
+
sseClients.delete(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* GET /api/trigger/scenarios/poll?target=... — Check for a queued scenario.
|
|
147
|
+
* Returns 200 with scenario if one is available, 204 otherwise.
|
|
148
|
+
* Kept for backwards compatibility — SSE stream is the preferred mechanism.
|
|
149
|
+
*/
|
|
150
|
+
triggerRouter.get("/scenarios/poll", (req: Request, res: Response) => {
|
|
151
|
+
const target = (req.query.target as string) || "";
|
|
152
|
+
|
|
153
|
+
const queued = scenarioManager.dequeueScenario(target);
|
|
154
|
+
if (queued) {
|
|
155
|
+
res.status(200).json(queued);
|
|
156
|
+
} else {
|
|
157
|
+
res.status(204).end();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* GET /api/trigger/scenarios/results — List all recent results.
|
|
163
|
+
* Must be registered before the :id param routes to avoid ambiguity.
|
|
164
|
+
*/
|
|
165
|
+
triggerRouter.get("/scenarios/results", (req: Request, res: Response) => {
|
|
166
|
+
const projectPath = req.query.projectPath as string | undefined;
|
|
167
|
+
res.json(scenarioManager.listResults(projectPath || null));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* POST /api/trigger/scenarios/:id/result — Receive result from browser after scenario completes.
|
|
172
|
+
*/
|
|
173
|
+
triggerRouter.post("/scenarios/:id/result", (req: Request, res: Response) => {
|
|
174
|
+
const id = req.params.id as string;
|
|
175
|
+
const { status, failedStep, error, duration } = req.body as {
|
|
176
|
+
status?: string;
|
|
177
|
+
failedStep?: number;
|
|
178
|
+
error?: string;
|
|
179
|
+
duration?: number;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (status !== "passed" && status !== "failed") {
|
|
183
|
+
res.status(400).end();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = scenarioManager.setResult(id, { status, failedStep, error, duration });
|
|
188
|
+
res.status(201).json(result);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* GET /api/trigger/scenarios/:id/result — Retrieve result for a scenario.
|
|
193
|
+
*/
|
|
194
|
+
triggerRouter.get("/scenarios/:id/result", (req: Request, res: Response) => {
|
|
195
|
+
const id = req.params.id as string;
|
|
196
|
+
const result = scenarioManager.getResult(id);
|
|
197
|
+
if (!result) {
|
|
198
|
+
res.status(404).end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
res.json(result);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const scenarioStore = ScenarioStore.getInstance();
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* GET /api/trigger/scenarios/saved — List saved scenarios scoped to a target.
|
|
208
|
+
* Requires ?target= (exact match) or ?projectPath= (matches exact path or basename).
|
|
209
|
+
* Returns empty array if neither is provided.
|
|
210
|
+
*/
|
|
211
|
+
triggerRouter.get("/scenarios/saved", async (req: Request, res: Response) => {
|
|
212
|
+
const target = req.query.target as string | undefined;
|
|
213
|
+
const projectPath = req.query.projectPath as string | undefined;
|
|
214
|
+
|
|
215
|
+
if (target) {
|
|
216
|
+
res.json(await scenarioStore.list(target));
|
|
217
|
+
} else if (projectPath) {
|
|
218
|
+
const basename = path.basename(projectPath);
|
|
219
|
+
const byPath = await scenarioStore.list(projectPath);
|
|
220
|
+
const byName = basename !== projectPath ? await scenarioStore.list(basename) : [];
|
|
221
|
+
// Dedupe in case both match the same scenarios
|
|
222
|
+
const seen = new Set(byPath.map((s) => s.id));
|
|
223
|
+
res.json([...byPath, ...byName.filter((s) => !seen.has(s.id))]);
|
|
224
|
+
} else {
|
|
225
|
+
res.json([]);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* POST /api/trigger/scenarios/save — Save a new scenario.
|
|
231
|
+
*/
|
|
232
|
+
triggerRouter.post("/scenarios/save", async (req: Request, res: Response) => {
|
|
233
|
+
const body = req.body as ScenarioBody;
|
|
234
|
+
|
|
235
|
+
if (!body.name || !body.target || !body.steps || body.steps.length === 0) {
|
|
236
|
+
res.status(400).end();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const saved = await scenarioStore.save({
|
|
241
|
+
name: body.name,
|
|
242
|
+
description: body.description,
|
|
243
|
+
target: body.target,
|
|
244
|
+
steps: body.steps,
|
|
245
|
+
});
|
|
246
|
+
res.status(201).json(saved);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* DELETE /api/trigger/scenarios/saved/:id — Delete a saved scenario by id.
|
|
251
|
+
*/
|
|
252
|
+
triggerRouter.delete("/scenarios/saved/:id", async (req: Request, res: Response) => {
|
|
253
|
+
const id = req.params.id as string;
|
|
254
|
+
const deleted = await scenarioStore.delete(id);
|
|
255
|
+
if (!deleted) {
|
|
256
|
+
res.status(404).end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
res.status(204).end();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* POST /api/trigger/scenarios/saved/:id/run — Re-run a saved scenario.
|
|
264
|
+
*/
|
|
265
|
+
triggerRouter.post("/scenarios/saved/:id/run", async (req: Request, res: Response) => {
|
|
266
|
+
const id = req.params.id as string;
|
|
267
|
+
const scenario = await scenarioStore.get(id);
|
|
268
|
+
if (!scenario) {
|
|
269
|
+
res.status(404).end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await scenarioStore.markRun(id);
|
|
274
|
+
|
|
275
|
+
const queued = scenarioManager.submitScenario({
|
|
276
|
+
name: scenario.name,
|
|
277
|
+
steps: scenario.steps,
|
|
278
|
+
target: scenario.target,
|
|
279
|
+
});
|
|
280
|
+
// Broadcast to any SSE clients listening for this target
|
|
281
|
+
if (queued.target) {
|
|
282
|
+
broadcastScenario(queued.target, queued);
|
|
283
|
+
}
|
|
284
|
+
res.status(201).json(queued);
|
|
285
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manages SSE client connections, heartbeats, and broadcast logic
|
|
5
|
+
* for scenario delivery to browser targets.
|
|
6
|
+
*/
|
|
7
|
+
export class ScenarioBroadcaster {
|
|
8
|
+
private static instance: ScenarioBroadcaster;
|
|
9
|
+
private clients = new Map<string, Set<Response>>();
|
|
10
|
+
private heartbeatTimer: ReturnType<typeof setInterval>;
|
|
11
|
+
|
|
12
|
+
private constructor() {
|
|
13
|
+
this.heartbeatTimer = setInterval(() => {
|
|
14
|
+
for (const clientSet of this.clients.values()) {
|
|
15
|
+
for (const res of clientSet) {
|
|
16
|
+
try { res.write(': heartbeat\n\n'); } catch { /* cleaned up on close */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}, 30_000);
|
|
20
|
+
this.heartbeatTimer.unref();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static getInstance(): ScenarioBroadcaster {
|
|
24
|
+
if (!ScenarioBroadcaster.instance) {
|
|
25
|
+
ScenarioBroadcaster.instance = new ScenarioBroadcaster();
|
|
26
|
+
}
|
|
27
|
+
return ScenarioBroadcaster.instance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Add an SSE client for a given target key. Returns a cleanup function. */
|
|
31
|
+
addClient(key: string, res: Response): () => void {
|
|
32
|
+
if (!this.clients.has(key)) {
|
|
33
|
+
this.clients.set(key, new Set());
|
|
34
|
+
}
|
|
35
|
+
this.clients.get(key)!.add(res);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
const clientSet = this.clients.get(key);
|
|
39
|
+
if (clientSet) {
|
|
40
|
+
clientSet.delete(res);
|
|
41
|
+
if (clientSet.size === 0) this.clients.delete(key);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Broadcast a scenario payload to all SSE clients for a target key. */
|
|
47
|
+
broadcast(key: string, scenario: unknown): void {
|
|
48
|
+
const clients = this.clients.get(key);
|
|
49
|
+
if (!clients || clients.size === 0) return;
|
|
50
|
+
const payload = `data: ${JSON.stringify(scenario)}\n\n`;
|
|
51
|
+
for (const res of clients) {
|
|
52
|
+
try { res.write(payload); } catch { /* cleaned up on close */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
shutdown(): void {
|
|
57
|
+
clearInterval(this.heartbeatTimer);
|
|
58
|
+
this.clients.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { v4 as uuidv4 } from "uuid";
|
|
3
|
+
|
|
4
|
+
interface TriggerStep {
|
|
5
|
+
command: string;
|
|
6
|
+
selector?: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
timeout?: number;
|
|
10
|
+
ms?: number;
|
|
11
|
+
clear?: boolean;
|
|
12
|
+
contains?: boolean;
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Scenario {
|
|
17
|
+
id: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
steps: TriggerStep[];
|
|
21
|
+
target?: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScenarioResult {
|
|
26
|
+
id: string;
|
|
27
|
+
status: "passed" | "failed";
|
|
28
|
+
failedStep?: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
duration?: number;
|
|
31
|
+
target?: string;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* In-memory scenario store with 5-minute auto-cleanup.
|
|
37
|
+
*/
|
|
38
|
+
export class ScenarioManager {
|
|
39
|
+
private static instance: ScenarioManager;
|
|
40
|
+
|
|
41
|
+
private scenariosByTarget = new Map<string, Scenario[]>();
|
|
42
|
+
private scenarioTargets = new Map<string, string>();
|
|
43
|
+
private results = new Map<string, ScenarioResult>();
|
|
44
|
+
private knownTargets = new Set<string>();
|
|
45
|
+
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
46
|
+
|
|
47
|
+
static getInstance(): ScenarioManager {
|
|
48
|
+
if (!ScenarioManager.instance) {
|
|
49
|
+
ScenarioManager.instance = new ScenarioManager();
|
|
50
|
+
}
|
|
51
|
+
return ScenarioManager.instance;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
submitScenario(body: {
|
|
55
|
+
name?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
steps?: TriggerStep[];
|
|
58
|
+
target?: string;
|
|
59
|
+
}): Scenario {
|
|
60
|
+
const scenario: Scenario = {
|
|
61
|
+
id: uuidv4(),
|
|
62
|
+
name: body.name,
|
|
63
|
+
description: body.description,
|
|
64
|
+
steps: body.steps || [],
|
|
65
|
+
target: body.target,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const key = this.targetKey(scenario.target);
|
|
70
|
+
|
|
71
|
+
const queue = this.scenariosByTarget.get(key) || [];
|
|
72
|
+
queue.push(scenario);
|
|
73
|
+
this.scenariosByTarget.set(key, queue);
|
|
74
|
+
|
|
75
|
+
if (scenario.target) {
|
|
76
|
+
this.scenarioTargets.set(scenario.id, key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return scenario;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setResult(id: string, result: Omit<ScenarioResult, "id" | "createdAt" | "target">): ScenarioResult {
|
|
83
|
+
const entry: ScenarioResult = {
|
|
84
|
+
id,
|
|
85
|
+
...result,
|
|
86
|
+
target: this.scenarioTargets.get(id),
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
this.results.set(id, entry);
|
|
90
|
+
this.scenarioTargets.delete(id);
|
|
91
|
+
return entry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getResult(id: string): ScenarioResult | undefined {
|
|
95
|
+
return this.results.get(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
listResults(projectPath?: string | null): ScenarioResult[] {
|
|
99
|
+
if (!projectPath) return [];
|
|
100
|
+
const all = Array.from(this.results.values());
|
|
101
|
+
const prefix = projectPath;
|
|
102
|
+
const basename = path.basename(projectPath);
|
|
103
|
+
return all.filter((r) => {
|
|
104
|
+
if (!r.target) return false;
|
|
105
|
+
return r.target.startsWith(prefix) || r.target === basename;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dequeueScenario(target: string): Scenario | undefined {
|
|
110
|
+
// Track every absolute path that browsers poll with
|
|
111
|
+
if (path.isAbsolute(target)) {
|
|
112
|
+
this.knownTargets.add(target);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const key = this.targetKey(target);
|
|
116
|
+
const queue = this.scenariosByTarget.get(key);
|
|
117
|
+
if (queue && queue.length > 0) {
|
|
118
|
+
const scenario = queue.shift()!;
|
|
119
|
+
if (queue.length === 0) this.scenariosByTarget.delete(key);
|
|
120
|
+
return scenario;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback: browser polls with absolute path, check for unresolved app-name keys
|
|
124
|
+
if (path.isAbsolute(target)) {
|
|
125
|
+
const basename = path.basename(target);
|
|
126
|
+
const fallbackQueue = this.scenariosByTarget.get(basename);
|
|
127
|
+
if (fallbackQueue && fallbackQueue.length > 0) {
|
|
128
|
+
const scenario = fallbackQueue.shift()!;
|
|
129
|
+
if (fallbackQueue.length === 0) this.scenariosByTarget.delete(basename);
|
|
130
|
+
return scenario;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
startCleanup(): void {
|
|
138
|
+
if (this.cleanupInterval) return;
|
|
139
|
+
this.cleanupInterval = setInterval(() => this.cleanupStale(), 60_000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
stopCleanup(): void {
|
|
143
|
+
if (this.cleanupInterval) {
|
|
144
|
+
clearInterval(this.cleanupInterval);
|
|
145
|
+
this.cleanupInterval = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private cleanupStale(): void {
|
|
150
|
+
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
151
|
+
for (const [key, queue] of this.scenariosByTarget) {
|
|
152
|
+
const filtered = queue.filter(
|
|
153
|
+
(s) => new Date(s.createdAt).getTime() >= cutoff
|
|
154
|
+
);
|
|
155
|
+
if (filtered.length === 0) {
|
|
156
|
+
this.scenariosByTarget.delete(key);
|
|
157
|
+
} else {
|
|
158
|
+
this.scenariosByTarget.set(key, filtered);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [id, result] of this.results) {
|
|
162
|
+
if (new Date(result.createdAt).getTime() < cutoff) {
|
|
163
|
+
this.results.delete(id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Clean orphaned target mappings (scenario submitted but never completed)
|
|
167
|
+
for (const id of this.scenarioTargets.keys()) {
|
|
168
|
+
if (!this.results.has(id)) {
|
|
169
|
+
// Check if scenario is still queued
|
|
170
|
+
let found = false;
|
|
171
|
+
for (const queue of this.scenariosByTarget.values()) {
|
|
172
|
+
if (queue.some((s) => s.id === id)) { found = true; break; }
|
|
173
|
+
}
|
|
174
|
+
if (!found) this.scenarioTargets.delete(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Public wrapper for targetKey — used by the SSE broadcast layer
|
|
181
|
+
* to resolve a target string to its canonical key.
|
|
182
|
+
*/
|
|
183
|
+
resolveTargetKey(target?: string): string {
|
|
184
|
+
return this.targetKey(target);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Register an absolute target path as known (normally done by dequeue,
|
|
189
|
+
* but SSE clients also need to register themselves).
|
|
190
|
+
*/
|
|
191
|
+
registerTarget(target: string): void {
|
|
192
|
+
if (path.isAbsolute(target)) {
|
|
193
|
+
this.knownTargets.add(target);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private targetKey(target?: string): string {
|
|
198
|
+
if (!target) return "";
|
|
199
|
+
// Already an absolute path — use as-is
|
|
200
|
+
if (path.isAbsolute(target)) return target;
|
|
201
|
+
// App name — search knownTargets for a path ending with /<target>
|
|
202
|
+
const suffix = `/${target}`;
|
|
203
|
+
for (const known of this.knownTargets) {
|
|
204
|
+
if (known.endsWith(suffix)) return known;
|
|
205
|
+
}
|
|
206
|
+
// No match yet — return the app name as-is (fallback at dequeue time)
|
|
207
|
+
return target;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getPendingCount(): number {
|
|
211
|
+
let total = 0;
|
|
212
|
+
for (const queue of this.scenariosByTarget.values()) {
|
|
213
|
+
total += queue.length;
|
|
214
|
+
}
|
|
215
|
+
return total;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getPendingCountForProject(projectPath: string | null): number {
|
|
219
|
+
if (!projectPath) return 0;
|
|
220
|
+
let count = 0;
|
|
221
|
+
for (const [target, scenarios] of this.scenariosByTarget) {
|
|
222
|
+
if (target.startsWith(projectPath)) count += scenarios.length;
|
|
223
|
+
}
|
|
224
|
+
return count;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getCommandsCatalog(): Record<string, unknown> {
|
|
228
|
+
return {
|
|
229
|
+
description:
|
|
230
|
+
"Console Trigger DSL — commands for browser UI automation via POST /api/trigger/scenarios",
|
|
231
|
+
usage:
|
|
232
|
+
"POST a JSON object with 'name' (string), optional 'description' (string), " +
|
|
233
|
+
"'target' (string), and 'steps' (array of command objects). Each step " +
|
|
234
|
+
"must have a 'command' field plus the required parameters listed below. Steps " +
|
|
235
|
+
"execute sequentially; execution stops on first failure. The 'target' field " +
|
|
236
|
+
"identifies which browser should run the scenario. It can be an absolute " +
|
|
237
|
+
"filesystem path (matching the devtools.js?target= query param, " +
|
|
238
|
+
"e.g. \"/home/user/devglide/apps/kanban\") or a simple app name " +
|
|
239
|
+
"(e.g. \"kanban\", \"dashboard\") which is automatically resolved to the " +
|
|
240
|
+
"full path once the browser has polled at least once. " +
|
|
241
|
+
"Use 'logPath' to check the current URL, 'logBody' and 'logHead' to inspect " +
|
|
242
|
+
"the rendered HTML (captured by console-sniffer from devglide-log). " +
|
|
243
|
+
"IMPORTANT: Prefer interacting with HTML elements (clicking links, buttons, nav items) " +
|
|
244
|
+
"over using 'navigate'. The 'navigate' command should only be used when there is no " +
|
|
245
|
+
"interactive element available — e.g. for the initial deep-link to a URL before the " +
|
|
246
|
+
"test starts. Normal in-app navigation must be done via click/type/select on real UI " +
|
|
247
|
+
"elements, because this reflects actual user behaviour. Overusing 'navigate' bypasses " +
|
|
248
|
+
"the UI interaction layer and can cause false positives where the scenario passes but " +
|
|
249
|
+
"the UI flow was never actually tested. " +
|
|
250
|
+
"When 'navigate' is used, scenario state is automatically persisted to localStorage " +
|
|
251
|
+
"and resumed after page reload. " +
|
|
252
|
+
"It is recommended to use persistent=true on the console-sniffer script tag when using 'navigate'.",
|
|
253
|
+
commands: [
|
|
254
|
+
cmd("click", "Click a DOM element", {
|
|
255
|
+
selector: param("string", true, "CSS selector for the target element"),
|
|
256
|
+
}),
|
|
257
|
+
cmd("dblclick", "Double-click a DOM element", {
|
|
258
|
+
selector: param("string", true, "CSS selector for the target element"),
|
|
259
|
+
}),
|
|
260
|
+
cmd(
|
|
261
|
+
"type",
|
|
262
|
+
"Type text into an input or textarea. Clears the field first by default.",
|
|
263
|
+
{
|
|
264
|
+
selector: param("string", true, "CSS selector for the input element"),
|
|
265
|
+
text: param("string", true, "Text to type"),
|
|
266
|
+
clear: param("boolean", false, "Clear the field before typing (default: true)"),
|
|
267
|
+
}
|
|
268
|
+
),
|
|
269
|
+
cmd("select", "Select an option in a <select> dropdown by its value attribute", {
|
|
270
|
+
selector: param("string", true, "CSS selector for the <select> element"),
|
|
271
|
+
value: param("string", true, "The option value to select"),
|
|
272
|
+
}),
|
|
273
|
+
cmd("wait", "Pause execution for a fixed number of milliseconds", {
|
|
274
|
+
ms: param("integer", true, "Number of milliseconds to wait"),
|
|
275
|
+
}),
|
|
276
|
+
cmd(
|
|
277
|
+
"waitFor",
|
|
278
|
+
"Wait until an element appears in the DOM (polls until found or timeout)",
|
|
279
|
+
{
|
|
280
|
+
selector: param("string", true, "CSS selector to wait for"),
|
|
281
|
+
timeout: param("integer", false, "Max wait time in ms (default: 5000)"),
|
|
282
|
+
}
|
|
283
|
+
),
|
|
284
|
+
cmd(
|
|
285
|
+
"waitForHidden",
|
|
286
|
+
"Wait until an element disappears from the DOM or becomes hidden",
|
|
287
|
+
{
|
|
288
|
+
selector: param("string", true, "CSS selector to wait for disappearance"),
|
|
289
|
+
timeout: param("integer", false, "Max wait time in ms (default: 5000)"),
|
|
290
|
+
}
|
|
291
|
+
),
|
|
292
|
+
cmd(
|
|
293
|
+
"find",
|
|
294
|
+
"Alias for waitFor — locate a DOM element, retrying until found or timeout.",
|
|
295
|
+
{
|
|
296
|
+
selector: param("string", true, "CSS selector for the element"),
|
|
297
|
+
timeout: param("integer", false, "Max wait time in ms (default: 5000)"),
|
|
298
|
+
}
|
|
299
|
+
),
|
|
300
|
+
cmd(
|
|
301
|
+
"assertExists",
|
|
302
|
+
"Assert that an element currently exists in the DOM. Fails immediately if not found.",
|
|
303
|
+
{
|
|
304
|
+
selector: param("string", true, "CSS selector to check"),
|
|
305
|
+
}
|
|
306
|
+
),
|
|
307
|
+
cmd(
|
|
308
|
+
"assertText",
|
|
309
|
+
"Assert that an element's text content matches or contains the expected text",
|
|
310
|
+
{
|
|
311
|
+
selector: param("string", true, "CSS selector for the element"),
|
|
312
|
+
text: param("string", true, "Expected text"),
|
|
313
|
+
contains: param(
|
|
314
|
+
"boolean",
|
|
315
|
+
false,
|
|
316
|
+
"If true (default), checks substring match. If false, checks exact match."
|
|
317
|
+
),
|
|
318
|
+
}
|
|
319
|
+
),
|
|
320
|
+
cmd(
|
|
321
|
+
"logPath",
|
|
322
|
+
"Log the current page URL to the console. Captured by console-sniffer so the backend can check the URL from the log file.",
|
|
323
|
+
{}
|
|
324
|
+
),
|
|
325
|
+
cmd(
|
|
326
|
+
"logBody",
|
|
327
|
+
"Log the current page body HTML to the console. Captured by console-sniffer so the backend can inspect the rendered DOM body.",
|
|
328
|
+
{}
|
|
329
|
+
),
|
|
330
|
+
cmd(
|
|
331
|
+
"logHead",
|
|
332
|
+
"Log the current page head HTML to the console. Captured by console-sniffer so the backend can inspect the page head element.",
|
|
333
|
+
{}
|
|
334
|
+
),
|
|
335
|
+
cmd(
|
|
336
|
+
"navigate",
|
|
337
|
+
"Navigate the browser to a given path. Use sparingly — only when no clickable " +
|
|
338
|
+
"element can achieve the navigation (e.g. initial page load or deep-linking " +
|
|
339
|
+
"before a test begins). For normal in-app navigation, prefer clicking links " +
|
|
340
|
+
"or buttons instead, as this better reflects real user behaviour and avoids " +
|
|
341
|
+
"false positives.",
|
|
342
|
+
{
|
|
343
|
+
path: param("string", true, "The URL path to navigate to (e.g. /dashboard, /users/123)"),
|
|
344
|
+
}
|
|
345
|
+
),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function param(type: string, required: boolean, description: string) {
|
|
352
|
+
return { type, required, description };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function cmd(
|
|
356
|
+
command: string,
|
|
357
|
+
description: string,
|
|
358
|
+
parameters: Record<string, { type: string; required: boolean; description: string }>
|
|
359
|
+
) {
|
|
360
|
+
return { command, description, parameters };
|
|
361
|
+
}
|