@aexol/spectral 0.9.41 → 0.9.43
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/dist/auth-helper.d.ts.map +1 -1
- package/dist/auth-helper.js +49 -11
- package/dist/extensions/aexol-mcp.d.ts.map +1 -1
- package/dist/extensions/aexol-mcp.js +44 -9
- package/dist/extensions/browser/browser-service.d.ts +4 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +34 -7
- package/dist/extensions/browser/config.d.ts +3 -0
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +1 -0
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +3 -0
- package/dist/extensions/browser/record/record-tool.d.ts.map +1 -1
- package/dist/extensions/browser/record/record-tool.js +27 -4
- package/dist/extensions/browser/scenario/markdown-parser.d.ts +44 -0
- package/dist/extensions/browser/scenario/markdown-parser.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/markdown-parser.js +159 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts +4 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/scenario-tool.js +381 -0
- package/dist/extensions/browser/tools/core.js +1 -1
- package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
- package/dist/extensions/browser/tools/screenshot.js +2 -1
- package/dist/extensions/seo/analyzers/agent-ux.d.ts +5 -0
- package/dist/extensions/seo/analyzers/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/agent-ux.js +95 -0
- package/dist/extensions/seo/analyzers/content.d.ts +16 -0
- package/dist/extensions/seo/analyzers/content.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/content.js +174 -0
- package/dist/extensions/seo/analyzers/drift.d.ts +13 -0
- package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/drift.js +204 -0
- package/dist/extensions/seo/analyzers/images.d.ts +10 -0
- package/dist/extensions/seo/analyzers/images.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/images.js +49 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts +18 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/parasite.js +142 -0
- package/dist/extensions/seo/analyzers/preload.d.ts +12 -0
- package/dist/extensions/seo/analyzers/preload.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/preload.js +108 -0
- package/dist/extensions/seo/analyzers/schema.d.ts +21 -0
- package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/schema.js +324 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts +20 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/sitemap.js +121 -0
- package/dist/extensions/seo/analyzers/technical.d.ts +11 -0
- package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/technical.js +268 -0
- package/dist/extensions/seo/config.d.ts +24 -0
- package/dist/extensions/seo/config.d.ts.map +1 -0
- package/dist/extensions/seo/config.js +48 -0
- package/dist/extensions/seo/index.d.ts +16 -0
- package/dist/extensions/seo/index.d.ts.map +1 -0
- package/dist/extensions/seo/index.js +88 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts +7 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/tools/agent-ux.js +63 -0
- package/dist/extensions/seo/tools/content.d.ts +7 -0
- package/dist/extensions/seo/tools/content.d.ts.map +1 -0
- package/dist/extensions/seo/tools/content.js +55 -0
- package/dist/extensions/seo/tools/drift.d.ts +7 -0
- package/dist/extensions/seo/tools/drift.d.ts.map +1 -0
- package/dist/extensions/seo/tools/drift.js +104 -0
- package/dist/extensions/seo/tools/fetch.d.ts +7 -0
- package/dist/extensions/seo/tools/fetch.d.ts.map +1 -0
- package/dist/extensions/seo/tools/fetch.js +123 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +10 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -0
- package/dist/extensions/seo/tools/full-audit.js +191 -0
- package/dist/extensions/seo/tools/helpers.d.ts +13 -0
- package/dist/extensions/seo/tools/helpers.d.ts.map +1 -0
- package/dist/extensions/seo/tools/helpers.js +24 -0
- package/dist/extensions/seo/tools/images.d.ts +7 -0
- package/dist/extensions/seo/tools/images.d.ts.map +1 -0
- package/dist/extensions/seo/tools/images.js +46 -0
- package/dist/extensions/seo/tools/parasite.d.ts +7 -0
- package/dist/extensions/seo/tools/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parasite.js +53 -0
- package/dist/extensions/seo/tools/parse.d.ts +7 -0
- package/dist/extensions/seo/tools/parse.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parse.js +66 -0
- package/dist/extensions/seo/tools/preload.d.ts +7 -0
- package/dist/extensions/seo/tools/preload.d.ts.map +1 -0
- package/dist/extensions/seo/tools/preload.js +52 -0
- package/dist/extensions/seo/tools/report.d.ts +4 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -0
- package/dist/extensions/seo/tools/report.js +167 -0
- package/dist/extensions/seo/tools/schema.d.ts +7 -0
- package/dist/extensions/seo/tools/schema.d.ts.map +1 -0
- package/dist/extensions/seo/tools/schema.js +105 -0
- package/dist/extensions/seo/tools/sitemap.d.ts +7 -0
- package/dist/extensions/seo/tools/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/tools/sitemap.js +85 -0
- package/dist/extensions/seo/tools/technical.d.ts +7 -0
- package/dist/extensions/seo/tools/technical.d.ts.map +1 -0
- package/dist/extensions/seo/tools/technical.js +62 -0
- package/dist/extensions/seo/types.d.ts +289 -0
- package/dist/extensions/seo/types.d.ts.map +1 -0
- package/dist/extensions/seo/types.js +7 -0
- package/dist/extensions/seo/utils/fetcher.d.ts +28 -0
- package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -0
- package/dist/extensions/seo/utils/fetcher.js +118 -0
- package/dist/extensions/seo/utils/parser.d.ts +8 -0
- package/dist/extensions/seo/utils/parser.d.ts.map +1 -0
- package/dist/extensions/seo/utils/parser.js +265 -0
- package/dist/extensions/seo/utils/renderer.d.ts +39 -0
- package/dist/extensions/seo/utils/renderer.d.ts.map +1 -0
- package/dist/extensions/seo/utils/renderer.js +190 -0
- package/dist/extensions/seo/utils/url-safety.d.ts +31 -0
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -0
- package/dist/extensions/seo/utils/url-safety.js +200 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
- package/package.json +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-helper.d.ts","sourceRoot":"","sources":["../src/auth-helper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAOrB,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;
|
|
1
|
+
{"version":3,"file":"auth-helper.d.ts","sourceRoot":"","sources":["../src/auth-helper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAOrB,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA8FD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,MAAM,CAAC,EAAE,MAAM,EACf,SAAS,GAAE,MAAyB,GACnC,OAAO,CAAC,WAAW,CAAC,CA8DtB;AAMD,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAUjD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAkDrE"}
|
package/dist/auth-helper.js
CHANGED
|
@@ -33,6 +33,34 @@ function deriveLandingUrl(backendUrl) {
|
|
|
33
33
|
}
|
|
34
34
|
return DEFAULT_BACKEND_URL;
|
|
35
35
|
}
|
|
36
|
+
function decodeJwtExp(jwt) {
|
|
37
|
+
const parts = jwt.split(".");
|
|
38
|
+
if (parts.length < 2)
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const payloadJson = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
42
|
+
const payload = JSON.parse(payloadJson);
|
|
43
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isJwtExpired(jwt, skewSec = 60) {
|
|
50
|
+
const exp = decodeJwtExp(jwt);
|
|
51
|
+
if (exp === null)
|
|
52
|
+
return true;
|
|
53
|
+
return Date.now() / 1000 + skewSec >= exp;
|
|
54
|
+
}
|
|
55
|
+
function validateCallbackToken(token) {
|
|
56
|
+
if (!token || token.split(".").length !== 3) {
|
|
57
|
+
return { ok: false, reason: "Authorization callback returned an invalid token format." };
|
|
58
|
+
}
|
|
59
|
+
if (isJwtExpired(token)) {
|
|
60
|
+
return { ok: false, reason: "Authorization callback returned an expired token." };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true };
|
|
63
|
+
}
|
|
36
64
|
/**
|
|
37
65
|
* Start a temporary HTTP server to receive the OAuth callback.
|
|
38
66
|
* Returns the server and a promise that resolves with the JWT token.
|
|
@@ -104,6 +132,10 @@ export async function runOAuthFlow(apiUrl, timeoutMs = OAUTH_TIMEOUT_MS) {
|
|
|
104
132
|
}
|
|
105
133
|
});
|
|
106
134
|
const token = await tokenPromise;
|
|
135
|
+
const validation = validateCallbackToken(token);
|
|
136
|
+
if (!validation.ok) {
|
|
137
|
+
return { ok: false, reason: validation.reason };
|
|
138
|
+
}
|
|
107
139
|
// Save the token, preserving existing config (especially teamApiKey).
|
|
108
140
|
const cfg = existingCfg ?? {
|
|
109
141
|
apiUrl: resolvedApiUrl,
|
|
@@ -159,19 +191,25 @@ export async function ensureAuthenticated() {
|
|
|
159
191
|
const token = existingCfg?.teamApiKey || existingCfg?.userJwt || "";
|
|
160
192
|
// If we have a token, verify it still works
|
|
161
193
|
if (token) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// Token is still valid
|
|
166
|
-
return { ok: true, config: existingCfg };
|
|
194
|
+
// If the stored user JWT is locally known to be expired, skip the network roundtrip
|
|
195
|
+
if (existingCfg?.userJwt && isJwtExpired(existingCfg.userJwt)) {
|
|
196
|
+
// fall through to OAuth
|
|
167
197
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
else {
|
|
199
|
+
try {
|
|
200
|
+
const client = new AexolMcpClient(apiUrl, token);
|
|
201
|
+
await client.listTools();
|
|
202
|
+
// Token is still valid
|
|
203
|
+
return { ok: true, config: existingCfg };
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (!isAuthError(err)) {
|
|
207
|
+
// Non-auth error (network, server down) — don't re-auth, just report
|
|
208
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
209
|
+
return { ok: false, config: existingCfg, reason: `Backend unreachable: ${msg}` };
|
|
210
|
+
}
|
|
211
|
+
// Token is invalid/expired — fall through to OAuth
|
|
173
212
|
}
|
|
174
|
-
// Token is invalid/expired — fall through to OAuth
|
|
175
213
|
}
|
|
176
214
|
}
|
|
177
215
|
// No valid token — run OAuth flow
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"aexol-mcp.d.ts","sourceRoot":"","sources":["../../src/extensions/aexol-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"aexol-mcp.d.ts","sourceRoot":"","sources":["../../src/extensions/aexol-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;AAsKnG,wBAA8B,iBAAiB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuShF"}
|
|
@@ -128,17 +128,46 @@ async function refreshBindingFromDisk() {
|
|
|
128
128
|
* Studio binding when they are not already set by the model. Only applies
|
|
129
129
|
* to remote tools (names that start with `remote_`).
|
|
130
130
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Tools that accept a `model` parameter. For these, the session model is
|
|
133
|
+
* always injected from ctx.model — the agent never picks a model itself.
|
|
134
|
+
*/
|
|
135
|
+
const MODEL_INJECTING_TOOLS = new Set([
|
|
136
|
+
"remote_start_from",
|
|
137
|
+
"remote_start_inference",
|
|
138
|
+
"remote_refine_inference_artifact",
|
|
139
|
+
"remote_refine_from_task",
|
|
140
|
+
"remote_refine_aexol_content",
|
|
141
|
+
]);
|
|
142
|
+
const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
|
|
143
|
+
const BUILT_IN_PREFIX = "built-in/";
|
|
144
|
+
function augmentParams(toolName, params, ctx) {
|
|
145
|
+
if (!toolName.startsWith("remote_"))
|
|
146
|
+
return { params };
|
|
134
147
|
const augmented = { ...params };
|
|
135
|
-
if (currentBinding
|
|
148
|
+
if (currentBinding?.teamId && !("teamId" in params)) {
|
|
136
149
|
augmented.teamId = currentBinding.teamId;
|
|
137
150
|
}
|
|
138
|
-
if (currentBinding
|
|
151
|
+
if (currentBinding?.projectId && !("projectId" in params)) {
|
|
139
152
|
augmented.projectId = currentBinding.projectId;
|
|
140
153
|
}
|
|
141
|
-
|
|
154
|
+
if (MODEL_INJECTING_TOOLS.has(toolName)) {
|
|
155
|
+
const model = ctx.model;
|
|
156
|
+
if (!model) {
|
|
157
|
+
return {
|
|
158
|
+
error: "No session model is selected. Select a Studio built-in model in the agent before running this task.",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (model.provider !== SPECTRAL_PROXY_USER_MODEL ||
|
|
162
|
+
!model.id.startsWith(BUILT_IN_PREFIX)) {
|
|
163
|
+
return {
|
|
164
|
+
error: `Session model "${model.provider}/${model.id}" is not a Studio built-in model. ` +
|
|
165
|
+
"Select a built-in model (e.g. aexol-coder) in the agent before running this task.",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
augmented.model = model.id.slice(BUILT_IN_PREFIX.length);
|
|
169
|
+
}
|
|
170
|
+
return { params: augmented };
|
|
142
171
|
}
|
|
143
172
|
export default async function aexolMcpExtension(ext) {
|
|
144
173
|
const cfg = await readConfig();
|
|
@@ -223,10 +252,16 @@ export default async function aexolMcpExtension(ext) {
|
|
|
223
252
|
label: toLabel(tool.name),
|
|
224
253
|
description: tool.description ?? "",
|
|
225
254
|
parameters,
|
|
226
|
-
async execute(_toolCallId, params) {
|
|
255
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
227
256
|
await refreshBindingFromDisk();
|
|
228
|
-
const augmented = augmentParams(tool.name, params);
|
|
229
|
-
|
|
257
|
+
const augmented = augmentParams(tool.name, params, ctx);
|
|
258
|
+
if ("error" in augmented) {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text: augmented.error }],
|
|
261
|
+
details: { isError: true },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const res = await client.callTool(tool.name, augmented.params);
|
|
230
265
|
const text = renderContentToString(res.content);
|
|
231
266
|
if (res.isError === true) {
|
|
232
267
|
// Surface backend tool errors as a textual result, not a thrown
|
|
@@ -75,7 +75,10 @@ export declare class BrowserService {
|
|
|
75
75
|
getNetworkRequest(index: number): NetworkRequest | undefined;
|
|
76
76
|
clearNetworkRequests(): void;
|
|
77
77
|
close(): Promise<void>;
|
|
78
|
-
startRecording(
|
|
78
|
+
startRecording(opts?: {
|
|
79
|
+
outputDir?: string;
|
|
80
|
+
filename?: string;
|
|
81
|
+
}): Promise<{
|
|
79
82
|
outputPath: string;
|
|
80
83
|
startedAt: number;
|
|
81
84
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,IAAI,EAAkB,MAAM,YAAY,CAAC;AAChF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAsBjD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,OAAO,CAA+B;IAC9C,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,aAAa,CAA8D;IACnF,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAKd;IACR,OAAO,CAAC,UAAU,
|
|
1
|
+
{"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -300,11 +300,11 @@ export class BrowserService {
|
|
|
300
300
|
this._recording = null;
|
|
301
301
|
}
|
|
302
302
|
// ── Recording ──────────────────────────────────────────────
|
|
303
|
-
async startRecording(
|
|
303
|
+
async startRecording(opts) {
|
|
304
304
|
if (this._recording) {
|
|
305
305
|
throw new Error("Recording is already active. Use stopRecording() first.");
|
|
306
306
|
}
|
|
307
|
-
const dir = outputDir ?? this.config.recordOutputDir;
|
|
307
|
+
const dir = opts?.outputDir ?? this.config.recordOutputDir;
|
|
308
308
|
const { mkdirSync } = await import("node:fs");
|
|
309
309
|
mkdirSync(dir, { recursive: true });
|
|
310
310
|
// Save current URL before tearing down
|
|
@@ -324,8 +324,9 @@ export class BrowserService {
|
|
|
324
324
|
}
|
|
325
325
|
// Create new browser+context with recording enabled
|
|
326
326
|
const pw = loadPlaywright();
|
|
327
|
+
const recordHeadless = this.config.recordHeadless;
|
|
327
328
|
const launchOpts = {
|
|
328
|
-
headless:
|
|
329
|
+
headless: recordHeadless,
|
|
329
330
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
|
|
330
331
|
};
|
|
331
332
|
if (!this.browser?.isConnected()) {
|
|
@@ -342,7 +343,10 @@ export class BrowserService {
|
|
|
342
343
|
}
|
|
343
344
|
}
|
|
344
345
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
345
|
-
const
|
|
346
|
+
const base = opts?.filename
|
|
347
|
+
? opts.filename.replace(/\.webm$/i, "")
|
|
348
|
+
: `recording-${timestamp}`;
|
|
349
|
+
const outputPath = `${dir}/${base}.webm`;
|
|
346
350
|
this.context = await this.browser.newContext({
|
|
347
351
|
viewport: this.config.recordViewport ?? this.config.viewport,
|
|
348
352
|
recordVideo: {
|
|
@@ -363,15 +367,18 @@ export class BrowserService {
|
|
|
363
367
|
this.pages = [page];
|
|
364
368
|
this.activePageIndex = 0;
|
|
365
369
|
await page.goto(currentUrl, { waitUntil: "domcontentloaded", timeout: this.config.navigationTimeout }).catch(() => { });
|
|
366
|
-
this._recording = { dir, startedAt: Date.now(), outputPath };
|
|
370
|
+
this._recording = { dir, startedAt: Date.now(), outputPath, filename: opts?.filename };
|
|
367
371
|
return { outputPath, startedAt: this._recording.startedAt };
|
|
368
372
|
}
|
|
369
373
|
async stopRecording() {
|
|
370
374
|
if (!this._recording) {
|
|
371
375
|
throw new Error("No active recording.");
|
|
372
376
|
}
|
|
373
|
-
const { outputPath, startedAt } = this._recording;
|
|
377
|
+
const { outputPath, startedAt, dir, filename } = this._recording;
|
|
374
378
|
const durationSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
379
|
+
// Capture the real Playwright-generated video path before closing context.
|
|
380
|
+
let realVideoPath = null;
|
|
381
|
+
const pageForVideo = this.pages[0];
|
|
375
382
|
// Close the recording context — Playwright finalizes the .webm on context.close()
|
|
376
383
|
if (this.context) {
|
|
377
384
|
await Promise.allSettled(this.pages.map((p) => p.close().catch(() => { })));
|
|
@@ -379,10 +386,30 @@ export class BrowserService {
|
|
|
379
386
|
await this.context.close().catch(() => { });
|
|
380
387
|
this.context = null;
|
|
381
388
|
}
|
|
389
|
+
if (pageForVideo) {
|
|
390
|
+
try {
|
|
391
|
+
const v = pageForVideo.video();
|
|
392
|
+
if (v)
|
|
393
|
+
realVideoPath = await v.path();
|
|
394
|
+
}
|
|
395
|
+
catch { /* ignore */ }
|
|
396
|
+
}
|
|
397
|
+
// If a custom filename was requested, rename the Playwright-generated file.
|
|
398
|
+
let finalPath = realVideoPath ?? outputPath;
|
|
399
|
+
if (realVideoPath && filename) {
|
|
400
|
+
const { renameSync } = await import("node:fs");
|
|
401
|
+
const base = filename.replace(/\.webm$/i, "");
|
|
402
|
+
const target = `${dir}/${base}.webm`;
|
|
403
|
+
try {
|
|
404
|
+
renameSync(realVideoPath, target);
|
|
405
|
+
finalPath = target;
|
|
406
|
+
}
|
|
407
|
+
catch { /* ignore rename failure, keep real path */ }
|
|
408
|
+
}
|
|
382
409
|
this._recording = null;
|
|
383
410
|
// Create a fresh non-recording context for continued use
|
|
384
411
|
await this.ensureBrowser();
|
|
385
|
-
return { outputPath, durationSeconds };
|
|
412
|
+
return { outputPath: finalPath, durationSeconds };
|
|
386
413
|
}
|
|
387
414
|
getRecordStatus() {
|
|
388
415
|
if (!this._recording)
|
|
@@ -14,6 +14,9 @@ export interface BrowserConfig {
|
|
|
14
14
|
width: number;
|
|
15
15
|
height: number;
|
|
16
16
|
};
|
|
17
|
+
/** Whether to run the browser headless during recordings. Defaults to false
|
|
18
|
+
* (headful) for higher-quality video capture, mirroring the demo recorder. */
|
|
19
|
+
recordHeadless: boolean;
|
|
17
20
|
}
|
|
18
21
|
export declare function getBrowserConfig(): BrowserConfig;
|
|
19
22
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;kFAC8E;IAC9E,cAAc,EAAE,OAAO,CAAC;CACzB;AACD,wBAAgB,gBAAgB,IAAI,aAAa,CAgBhD"}
|
|
@@ -12,5 +12,6 @@ export function getBrowserConfig() {
|
|
|
12
12
|
debug: process.env.BROWSER_DEBUG === "true" ? true : false,
|
|
13
13
|
recordOutputDir: process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings",
|
|
14
14
|
recordViewport: undefined, // defaults to viewport via code
|
|
15
|
+
recordHeadless: process.env.BROWSER_RECORD_HEADLESS === "true" ? true : false,
|
|
15
16
|
};
|
|
16
17
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAgBpE,wBAA8B,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA8D/E"}
|
|
@@ -43,6 +43,7 @@ import { registerWaitTools } from "./tools/wait.js";
|
|
|
43
43
|
import { registerDropTools } from "./tools/drop.js";
|
|
44
44
|
import { registerStreamTools } from "./stream/stream-tool.js";
|
|
45
45
|
import { registerRecordTools } from "./record/record-tool.js";
|
|
46
|
+
import { registerScenarioTools } from "./scenario/scenario-tool.js";
|
|
46
47
|
export default async function browserExtension(ext) {
|
|
47
48
|
const available = BrowserService.isAvailable();
|
|
48
49
|
if (!available) {
|
|
@@ -79,6 +80,7 @@ export default async function browserExtension(ext) {
|
|
|
79
80
|
{ name: "drop", fn: () => registerDropTools(ext, bs) },
|
|
80
81
|
{ name: "stream", fn: () => registerStreamTools(ext, bs) },
|
|
81
82
|
{ name: "record", fn: () => registerRecordTools(ext, bs) },
|
|
83
|
+
{ name: "scenario", fn: () => registerScenarioTools(ext, bs) },
|
|
82
84
|
];
|
|
83
85
|
let totalRegistered = 0;
|
|
84
86
|
for (const registrar of registrars) {
|
|
@@ -116,6 +118,7 @@ function countRegisteredTools(ext) {
|
|
|
116
118
|
"browser_drop",
|
|
117
119
|
"browser_stream_start", "browser_stream_stop", "browser_stream_status",
|
|
118
120
|
"browser_record_start", "browser_record_stop", "browser_record_status",
|
|
121
|
+
"browser_run_scenario",
|
|
119
122
|
];
|
|
120
123
|
for (const name of knownTools) {
|
|
121
124
|
if (ext.getToolDefinition(name))
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/record/record-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"record-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/record/record-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAc5D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAmJ/E"}
|
|
@@ -1,12 +1,26 @@
|
|
|
1
|
+
/** Build ffmpeg conversion hints for a recorded .webm file. */
|
|
2
|
+
function ffmpegHints(webmPath) {
|
|
3
|
+
const mp4 = webmPath.replace(/\.webm$/i, ".mp4");
|
|
4
|
+
const gif = webmPath.replace(/\.webm$/i, ".gif");
|
|
5
|
+
return [
|
|
6
|
+
"Convert to MP4:",
|
|
7
|
+
` ffmpeg -i "${webmPath}" -c:v libx264 -crf 23 "${mp4}"`,
|
|
8
|
+
"Convert to GIF:",
|
|
9
|
+
` ffmpeg -i "${webmPath}" -vf "fps=15,scale=1280:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" "${gif}"`,
|
|
10
|
+
].join("\n");
|
|
11
|
+
}
|
|
1
12
|
export function registerRecordTools(ext, bs) {
|
|
2
13
|
const startTool = {
|
|
3
14
|
name: "browser_record_start",
|
|
4
15
|
label: "Start Browser Recording",
|
|
5
16
|
description: "Start recording the browser session as a .webm video. " +
|
|
17
|
+
"Use this when the user asks to record/demo/capture/walk through a browser session as video. " +
|
|
6
18
|
"Uses Playwright's built-in video recording (actual page capture, not a canvas overlay). " +
|
|
7
19
|
"Recording continues across navigation, clicks, typing — everything the agent does in the browser. " +
|
|
8
|
-
"Use browser_record_stop to finalize and save the video."
|
|
9
|
-
|
|
20
|
+
"Use browser_record_stop to finalize and save the video. " +
|
|
21
|
+
"TIP: for full multi-step scenario walkthroughs with recording, prefer browser_run_scenario with record:true " +
|
|
22
|
+
"instead of manually wrapping browser steps with start/stop.",
|
|
23
|
+
promptSnippet: "`browser_record_start { outputDir?, filename? }` — start recording browser as .webm video (prefer browser_run_scenario for scenario walkthroughs)",
|
|
10
24
|
parameters: {
|
|
11
25
|
type: "object",
|
|
12
26
|
properties: {
|
|
@@ -14,12 +28,19 @@ export function registerRecordTools(ext, bs) {
|
|
|
14
28
|
type: "string",
|
|
15
29
|
description: "Directory to save the recording. Default: .spectral/browser-recordings/ (configurable via BROWSER_RECORD_DIR env).",
|
|
16
30
|
},
|
|
31
|
+
filename: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Optional base filename (without extension) for the recording, e.g. 'login-demo'. Defaults to a timestamp-based name.",
|
|
34
|
+
},
|
|
17
35
|
},
|
|
18
36
|
},
|
|
19
37
|
async execute(_toolCallId, params) {
|
|
20
38
|
const p = params;
|
|
21
39
|
try {
|
|
22
|
-
const { outputPath, startedAt } = await bs.startRecording(
|
|
40
|
+
const { outputPath, startedAt } = await bs.startRecording({
|
|
41
|
+
outputDir: p.outputDir,
|
|
42
|
+
filename: p.filename,
|
|
43
|
+
});
|
|
23
44
|
return {
|
|
24
45
|
content: [
|
|
25
46
|
{
|
|
@@ -50,7 +71,7 @@ export function registerRecordTools(ext, bs) {
|
|
|
50
71
|
name: "browser_record_stop",
|
|
51
72
|
label: "Stop Browser Recording",
|
|
52
73
|
description: "Stop the active recording, finalize the .webm video, and create a fresh non-recording browser context " +
|
|
53
|
-
"for continued use. Returns the video file path and
|
|
74
|
+
"for continued use. Returns the video file path, recording duration, and ffmpeg conversion commands.",
|
|
54
75
|
promptSnippet: "`browser_record_stop` — stop recording and save .webm video",
|
|
55
76
|
parameters: {
|
|
56
77
|
type: "object",
|
|
@@ -68,6 +89,8 @@ export function registerRecordTools(ext, bs) {
|
|
|
68
89
|
`File: ${outputPath}`,
|
|
69
90
|
`Duration: ${durationSeconds}s (${Math.floor(durationSeconds / 60)}m ${durationSeconds % 60}s)`,
|
|
70
91
|
"",
|
|
92
|
+
ffmpegHints(outputPath),
|
|
93
|
+
"",
|
|
71
94
|
"A fresh browser context has been created — you can continue browsing.",
|
|
72
95
|
].join("\n"),
|
|
73
96
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a markdown test scenario into a list of executable browser steps.
|
|
3
|
+
*
|
|
4
|
+
* Recognises the scenario format produced by @scenario_writer, e.g.:
|
|
5
|
+
*
|
|
6
|
+
* ### Steps
|
|
7
|
+
* 1. **Navigate** to http://localhost:3008
|
|
8
|
+
* 2. **Click** the "Login" button
|
|
9
|
+
* 3. **Type** `user@example.com` into the email field
|
|
10
|
+
* 4. **Wait** for text "Dashboard" to appear
|
|
11
|
+
* 5. **Screenshot** checkpoint
|
|
12
|
+
*
|
|
13
|
+
* Each numbered list item becomes one step. The leading bold token (e.g.
|
|
14
|
+
* **Click**) maps to a browser action. Unrecognised actions are returned as
|
|
15
|
+
* `note` steps so the caller can still record timing without failing.
|
|
16
|
+
*/
|
|
17
|
+
export type ScenarioAction = "navigate" | "click" | "type" | "press" | "select" | "hover" | "wait" | "screenshot" | "evaluate" | "fill_form" | "note";
|
|
18
|
+
export interface ScenarioStep {
|
|
19
|
+
action: ScenarioAction;
|
|
20
|
+
/** Raw original line text, for reporting. */
|
|
21
|
+
raw: string;
|
|
22
|
+
/** Primary target: CSS selector, text, or URL depending on action. */
|
|
23
|
+
target?: string;
|
|
24
|
+
/** Value to type / press / select / wait-for. */
|
|
25
|
+
value?: string;
|
|
26
|
+
/** Extra form fields for fill_form: [{ name, value }]. */
|
|
27
|
+
fields?: Array<{
|
|
28
|
+
name: string;
|
|
29
|
+
value: string;
|
|
30
|
+
}>;
|
|
31
|
+
/** Per-step pause (ms) after the action, for readable video. */
|
|
32
|
+
waitMs?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse a markdown scenario string into steps.
|
|
36
|
+
*
|
|
37
|
+
* Only numbered list items (`1.`, `2.`, ...) inside a "### Steps" section (or
|
|
38
|
+
* anywhere in the document if no such heading exists) are converted. Other
|
|
39
|
+
* content is ignored.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseMarkdownScenario(md: string): ScenarioStep[];
|
|
42
|
+
/** Read a scenario file from disk and parse it. */
|
|
43
|
+
export declare function parseMarkdownScenarioFile(filePath: string): Promise<ScenarioStep[]>;
|
|
44
|
+
//# sourceMappingURL=markdown-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown-parser.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/markdown-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,MAAM,cAAc,GACtB,UAAU,GACV,OAAO,GACP,MAAM,GACN,OAAO,GACP,QAAQ,GACR,OAAO,GACP,MAAM,GACN,YAAY,GACZ,UAAU,GACV,WAAW,GACX,MAAM,CAAC;AAEX,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,cAAc,CAAC;IACvB,6CAA6C;IAC7C,GAAG,EAAE,MAAM,CAAC;IACZ,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChD,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA8GD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,EAAE,CA6BhE;AAED,mDAAmD;AACnD,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,YAAY,EAAE,CAAC,CAIzB"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a markdown test scenario into a list of executable browser steps.
|
|
3
|
+
*
|
|
4
|
+
* Recognises the scenario format produced by @scenario_writer, e.g.:
|
|
5
|
+
*
|
|
6
|
+
* ### Steps
|
|
7
|
+
* 1. **Navigate** to http://localhost:3008
|
|
8
|
+
* 2. **Click** the "Login" button
|
|
9
|
+
* 3. **Type** `user@example.com` into the email field
|
|
10
|
+
* 4. **Wait** for text "Dashboard" to appear
|
|
11
|
+
* 5. **Screenshot** checkpoint
|
|
12
|
+
*
|
|
13
|
+
* Each numbered list item becomes one step. The leading bold token (e.g.
|
|
14
|
+
* **Click**) maps to a browser action. Unrecognised actions are returned as
|
|
15
|
+
* `note` steps so the caller can still record timing without failing.
|
|
16
|
+
*/
|
|
17
|
+
const ACTION_KEYWORDS = {
|
|
18
|
+
navigate: "navigate",
|
|
19
|
+
go: "navigate",
|
|
20
|
+
open: "navigate",
|
|
21
|
+
visit: "navigate",
|
|
22
|
+
click: "click",
|
|
23
|
+
tap: "click",
|
|
24
|
+
type: "type",
|
|
25
|
+
enter: "type",
|
|
26
|
+
fill: "type",
|
|
27
|
+
press: "press",
|
|
28
|
+
key: "press",
|
|
29
|
+
select: "select",
|
|
30
|
+
choose: "select",
|
|
31
|
+
hover: "hover",
|
|
32
|
+
wait: "wait",
|
|
33
|
+
pause: "wait",
|
|
34
|
+
screenshot: "screenshot",
|
|
35
|
+
snapshot: "screenshot",
|
|
36
|
+
capture: "screenshot",
|
|
37
|
+
evaluate: "evaluate",
|
|
38
|
+
run: "evaluate",
|
|
39
|
+
fill_form: "fill_form",
|
|
40
|
+
fillform: "fill_form",
|
|
41
|
+
};
|
|
42
|
+
/** Extract the first bold token (e.g. **Click**) and map it to an action. */
|
|
43
|
+
function parseAction(line) {
|
|
44
|
+
const bold = line.match(/\*\*([A-Za-z_]+)\*\*/);
|
|
45
|
+
if (bold) {
|
|
46
|
+
const key = bold[1].toLowerCase().replace(/\s+/g, "_");
|
|
47
|
+
if (ACTION_KEYWORDS[key])
|
|
48
|
+
return ACTION_KEYWORDS[key];
|
|
49
|
+
}
|
|
50
|
+
// Fallback: first word of the line.
|
|
51
|
+
const first = line.trim().split(/[\s,]+/)[0]?.toLowerCase().replace(/[^a-z_]/g, "");
|
|
52
|
+
if (first && ACTION_KEYWORDS[first])
|
|
53
|
+
return ACTION_KEYWORDS[first];
|
|
54
|
+
return "note";
|
|
55
|
+
}
|
|
56
|
+
/** Extract a backtick-quoted code/value from the line. */
|
|
57
|
+
function parseBacktick(line) {
|
|
58
|
+
const m = line.match(/`([^`]+)`/);
|
|
59
|
+
return m ? m[1] : undefined;
|
|
60
|
+
}
|
|
61
|
+
/** Extract a quoted string (double or single quotes) from the line. */
|
|
62
|
+
function parseQuoted(line) {
|
|
63
|
+
const m = line.match(/"([^"]+)"|'([^']+)'/);
|
|
64
|
+
return m ? (m[1] ?? m[2]) : undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Extract a URL from the line. */
|
|
67
|
+
function parseUrl(line) {
|
|
68
|
+
const m = line.match(/https?:\/\/[^\s)"`]+/);
|
|
69
|
+
return m ? m[0] : undefined;
|
|
70
|
+
}
|
|
71
|
+
function buildStep(line, index) {
|
|
72
|
+
const raw = line.trim();
|
|
73
|
+
const action = parseAction(raw);
|
|
74
|
+
const step = { action, raw: `Step ${index}: ${raw}`, waitMs: undefined };
|
|
75
|
+
switch (action) {
|
|
76
|
+
case "navigate": {
|
|
77
|
+
step.target = parseUrl(raw) ?? parseBacktick(raw) ?? parseQuoted(raw);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "click":
|
|
81
|
+
case "hover": {
|
|
82
|
+
step.target = parseQuoted(raw) ?? parseBacktick(raw);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "type": {
|
|
86
|
+
// "Type `value` into <target>" — value is backtick, target is quoted.
|
|
87
|
+
step.value = parseBacktick(raw);
|
|
88
|
+
step.target = parseQuoted(raw);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "press": {
|
|
92
|
+
step.value = parseBacktick(raw) ?? parseQuoted(raw);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "select": {
|
|
96
|
+
step.target = parseQuoted(raw);
|
|
97
|
+
step.value = parseBacktick(raw);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "wait": {
|
|
101
|
+
// "Wait for text 'X'" or "Wait 500ms"
|
|
102
|
+
step.value = parseQuoted(raw) ?? parseBacktick(raw);
|
|
103
|
+
const ms = raw.match(/(\d+)\s*ms/);
|
|
104
|
+
if (ms) {
|
|
105
|
+
step.value = ms[1];
|
|
106
|
+
step.waitMs = Number(ms[1]);
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "screenshot":
|
|
111
|
+
case "note":
|
|
112
|
+
case "evaluate":
|
|
113
|
+
case "fill_form": {
|
|
114
|
+
step.target = parseBacktick(raw) ?? parseQuoted(raw);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return step;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Parse a markdown scenario string into steps.
|
|
122
|
+
*
|
|
123
|
+
* Only numbered list items (`1.`, `2.`, ...) inside a "### Steps" section (or
|
|
124
|
+
* anywhere in the document if no such heading exists) are converted. Other
|
|
125
|
+
* content is ignored.
|
|
126
|
+
*/
|
|
127
|
+
export function parseMarkdownScenario(md) {
|
|
128
|
+
const lines = md.split("\n");
|
|
129
|
+
const steps = [];
|
|
130
|
+
let inStepsSection = false;
|
|
131
|
+
let stepIndex = 0;
|
|
132
|
+
// Match "1." "2." etc. at line start (after optional whitespace).
|
|
133
|
+
const numberedRe = /^\s*\d+\.\s+(.+)/;
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const trimmed = line.trim();
|
|
136
|
+
const lower = trimmed.toLowerCase();
|
|
137
|
+
// Track whether we are inside a Steps section.
|
|
138
|
+
if (/^#{1,6}\s+/.test(trimmed)) {
|
|
139
|
+
inStepsSection = /step/i.test(lower);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!inStepsSection) {
|
|
143
|
+
// Still allow top-level numbered steps if no Steps heading has been seen
|
|
144
|
+
// yet — but only once we encounter the first numbered item.
|
|
145
|
+
}
|
|
146
|
+
const m = line.match(numberedRe);
|
|
147
|
+
if (m && m[1].trim().length > 0) {
|
|
148
|
+
stepIndex++;
|
|
149
|
+
steps.push(buildStep(m[1], stepIndex));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return steps;
|
|
153
|
+
}
|
|
154
|
+
/** Read a scenario file from disk and parse it. */
|
|
155
|
+
export async function parseMarkdownScenarioFile(filePath) {
|
|
156
|
+
const { readFile } = await import("node:fs/promises");
|
|
157
|
+
const content = await readFile(filePath, "utf-8");
|
|
158
|
+
return parseMarkdownScenario(content);
|
|
159
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
|
|
2
|
+
import type { BrowserService } from "../browser-service.js";
|
|
3
|
+
export declare function registerScenarioTools(ext: ExtensionAPI, bs: BrowserService): void;
|
|
4
|
+
//# sourceMappingURL=scenario-tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAsC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAuUjF"}
|