@skyramp/mcp 0.2.1 → 0.2.3

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.
@@ -44,6 +44,10 @@ var import_skyRampExport = require("../test/skyRampExport");
44
44
  var import_exportTool = require("./exportTool");
45
45
  var import_assertTool = require("./assertTool");
46
46
  var import_assertApiRequestTool = require("./assertApiRequestTool");
47
+ var import_loadTraceTool = require("./loadTraceTool");
48
+ var import_skyRampImport = require("./skyRampImport");
49
+ var import_mouseActionTool = require("./mouseActionTool");
50
+ var import_utils = require("playwright-core/lib/utils");
47
51
  var import_types = require("./types");
48
52
  const traceDebug = (0, import_utilsBundle.debug)("pw:mcp:trace");
49
53
  class TraceRecordingBackend {
@@ -116,7 +120,7 @@ class TraceRecordingBackend {
116
120
  }
117
121
  async listTools() {
118
122
  const browserTools = await this._browserBackend.listTools();
119
- return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)()];
123
+ return [...browserTools, (0, import_exportTool.exportZipMcpTool)(), (0, import_assertTool.assertMcpTool)(), (0, import_assertApiRequestTool.assertApiRequestMcpTool)(), (0, import_loadTraceTool.loadTraceMcpTool)(), (0, import_mouseActionTool.mouseActionMcpTool)()];
120
124
  }
121
125
  async callTool(name, args, progress) {
122
126
  if (!this._initialized)
@@ -139,6 +143,14 @@ class TraceRecordingBackend {
139
143
  }
140
144
  return exportResult;
141
145
  }
146
+ if (name === import_loadTraceTool.loadTraceSchema.name) {
147
+ const parsed = import_loadTraceTool.loadTraceSchema.inputSchema.parse(args || {});
148
+ return this._handleLoadTrace(parsed);
149
+ }
150
+ if (name === import_mouseActionTool.mouseActionSchema.name) {
151
+ const parsed = import_mouseActionTool.mouseActionSchema.inputSchema.parse(args || {});
152
+ return this._handleMouseAction(parsed);
153
+ }
142
154
  if (name === import_assertTool.assertToolSchema.name) {
143
155
  const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
144
156
  return this._handleAssert(parsed);
@@ -313,6 +325,280 @@ Reloaded current page: ${currentUrl}
313
325
  serverClosed() {
314
326
  void this._autoExportAndClose().catch(import_log.logUnhandledError);
315
327
  }
328
+ /**
329
+ * Execute a coordinate-based mouse action and track each decomposed sub-action
330
+ * (mouse.move/down/up/wheel or a position click) into _trackedActions so it is
331
+ * exported into the combined JSONL trace. The JSONL field shapes come straight
332
+ * from common/mouseActions, which is the single source of truth shared with
333
+ * the export and seeding paths.
334
+ */
335
+ async _handleMouseAction(params) {
336
+ const page = this._browserBackend.context?.currentTab()?.page;
337
+ const timestamp = Date.now();
338
+ const pageAlias = this._currentPageAlias;
339
+ const { result, actions } = await (0, import_mouseActionTool.executeMouseAction)(page, params);
340
+ if (result.isError)
341
+ return result;
342
+ actions.forEach((a, i) => {
343
+ this._trackedActions.push({
344
+ toolName: "browser_mouse_action",
345
+ args: a,
346
+ code: "",
347
+ // Stagger timestamps so a decomposed drag keeps its move<down<move<up order.
348
+ timestamp: timestamp + i,
349
+ pageAlias
350
+ });
351
+ });
352
+ traceDebug(`Tracked ${actions.length} mouse sub-action(s) for "${params.action}" on ${pageAlias}`);
353
+ return result;
354
+ }
355
+ /**
356
+ * Load a prior Skyramp trace and replay it against the live browser, honoring
357
+ * an optional stop point, then seed _trackedActions with the replayed actions
358
+ * so the model can continue recording and export a combined trace.
359
+ */
360
+ async _handleLoadTrace(params) {
361
+ const stopParams = ["stopAtStep", "stopAtUrl", "stopBefore"].filter((k) => {
362
+ const v = params[k];
363
+ return v !== void 0 && v !== "";
364
+ });
365
+ if (stopParams.length > 1)
366
+ return { content: [{ type: "text", text: `### Error
367
+ Pass at most ONE of stopAtStep / stopAtUrl / stopBefore (got ${stopParams.join(", ")}). Omit all three to replay the whole trace.` }], isError: true };
368
+ let loadedActions;
369
+ try {
370
+ loadedActions = await (0, import_skyRampImport.actionsFromPath)(params.path);
371
+ } catch (e) {
372
+ return { content: [{ type: "text", text: `### Error
373
+ Failed to load trace from ${params.path}: ${e.message}` }], isError: true };
374
+ }
375
+ if (!loadedActions.length)
376
+ return { content: [{ type: "text", text: `### Error
377
+ No actions found in ${params.path}.` }], isError: true };
378
+ if (params.dryRun) {
379
+ const stepList = loadedActions.map((a, i) => (0, import_loadTraceTool.describeStep)(a, i)).join("\n");
380
+ return {
381
+ content: [{
382
+ type: "text",
383
+ text: `### Trace loaded (dry run)
384
+ ${loadedActions.length} steps in ${params.path}. Nothing was replayed.
385
+
386
+ Steps:
387
+ ${stepList}
388
+
389
+ Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none of them to replay all) to replay.`
390
+ }]
391
+ };
392
+ }
393
+ try {
394
+ await this._browserBackend.context.ensureTab();
395
+ } catch {
396
+ }
397
+ let result;
398
+ try {
399
+ result = await (0, import_loadTraceTool.replayActions)(loadedActions, { pageForAlias: (alias) => this._pageForAlias(alias) }, params);
400
+ } catch (e) {
401
+ return { content: [{ type: "text", text: `### Error
402
+ Replay failed: ${e.message}` }], isError: true };
403
+ }
404
+ for (let i = 0; i < result.stopIndex; i++)
405
+ this._seedTrackedAction(loadedActions[i]);
406
+ if (result.stopIndex > 0)
407
+ this._currentPageAlias = loadedActions[result.stopIndex - 1].frame.pageAlias || "page";
408
+ const remaining = (0, import_loadTraceTool.listStepsFrom)(loadedActions, result.stopIndex);
409
+ const stoppedNote = (0, import_loadTraceTool.describeStopReason)(result);
410
+ const errorNote = result.error ? `
411
+
412
+ \u26A0\uFE0F Replay stopped at step ${result.error.stepIndex} (${result.error.actionName}): ${result.error.message}
413
+ You can continue recording from this point or fix the issue.` : "";
414
+ return {
415
+ content: [{
416
+ type: "text",
417
+ text: `### Trace replayed
418
+ ${result.completedCount} of ${loadedActions.length} steps replayed from ${params.path}. ${stoppedNote}${errorNote}
419
+
420
+ Remaining steps (NOT replayed):
421
+ ${remaining}
422
+
423
+ Continue recording with browser_* tools, then call skyramp_export_zip to write the combined trace.`
424
+ }],
425
+ isError: result.stopReason === "error"
426
+ };
427
+ }
428
+ /**
429
+ * Resolve a JSONL page alias to a live page. Numeric aliases ('page',
430
+ * 'pageN') map by tab index; semantic aliases ('popupPage') match by the
431
+ * popup URL pattern, falling back to the most recently opened tab.
432
+ */
433
+ _pageForAlias(alias) {
434
+ const tabs = this._browserBackend.context?.tabs() ?? [];
435
+ if (!tabs.length)
436
+ return void 0;
437
+ const numeric = alias === "page" ? 0 : /^page(\d+)$/.exec(alias)?.[1];
438
+ if (numeric !== void 0 && numeric !== null) {
439
+ const idx = Number(numeric);
440
+ return (tabs[idx] ?? tabs[tabs.length - 1]).page;
441
+ }
442
+ const popupTab = tabs.find((t) => /chrome-extension:\/\/[a-p]{32}\/popup\//.test(t.page.url()));
443
+ return (popupTab ?? tabs[tabs.length - 1]).page;
444
+ }
445
+ /**
446
+ * Convert a replayed ActionInContext back into a TrackedAction so it merges
447
+ * with freshly-recorded actions. The export pipeline (buildJsonlContent)
448
+ * re-derives the JSONL selector from the `code` field, so we regenerate clean
449
+ * Playwright code from the stored internal: selector via asLocator.
450
+ */
451
+ _seedTrackedAction(action) {
452
+ const a = action.action;
453
+ const pageAlias = action.frame.pageAlias || "page";
454
+ if (a.name === "navigate") {
455
+ this._trackedActions.push({
456
+ toolName: "browser_navigate",
457
+ args: { url: a.url },
458
+ code: "",
459
+ timestamp: action.startTime,
460
+ pageAlias
461
+ });
462
+ return;
463
+ }
464
+ const locatorExpr = a.selector ? (0, import_utils.asLocator)("javascript", a.selector) : "";
465
+ const seeded = this._seedTrackedActionFields(a, locatorExpr);
466
+ if (!seeded)
467
+ return;
468
+ this._trackedActions.push({
469
+ ...seeded,
470
+ timestamp: action.startTime,
471
+ pageAlias,
472
+ framePath: action.frame.framePath?.length ? action.frame.framePath : void 0
473
+ });
474
+ }
475
+ /**
476
+ * Build the { toolName, code, args } triple a seeded (replayed) action must
477
+ * carry so it round-trips through skyRampExport.buildJsonlContent exactly as
478
+ * a freshly-recorded action would. toolName, code and args are emitted
479
+ * together (not via separate mappers) because export keys on all three:
480
+ * trackedActionToJsonl dispatches on toolName, derives the locator from code,
481
+ * and reads specific args (checked, values, type). A mismatch silently
482
+ * mis-exports (e.g. hover as click) or drops the action.
483
+ *
484
+ * Code uses single quotes to match the live recorder's format
485
+ * (extractFillText / extractSelectOptions only match single-quoted calls).
486
+ * Returns null for unsupported actions so they are skipped, never defaulted.
487
+ */
488
+ _seedTrackedActionFields(a, locatorExpr) {
489
+ const sq = (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}'`;
490
+ if (a.name === "mouse.move" || a.name === "mouse.down" || a.name === "mouse.up" || a.name === "mouse.wheel") {
491
+ const args = this._seedMouseActionArgs(a);
492
+ if (!args)
493
+ return null;
494
+ return { toolName: "browser_mouse_action", code: "", args };
495
+ }
496
+ if (a.name === "click" && a.position && (!a.selector || a.selector === "body"))
497
+ return { toolName: "browser_mouse_action", code: "", args: { name: "click", selector: "body", position: a.position, button: a.button ?? "left", modifiers: a.modifiers ?? 0, clickCount: a.clickCount ?? 1 } };
498
+ const SELECTOR_REQUIRED = /* @__PURE__ */ new Set(["click", "hover", "fill", "pressSequentially", "check", "uncheck", "select"]);
499
+ if (SELECTOR_REQUIRED.has(a.name) && !locatorExpr)
500
+ return null;
501
+ const root = a.frameUrlMatch ? `page.frames().find(f => f.url().includes(${sq(a.frameUrlMatch)}))!` : "page";
502
+ switch (a.name) {
503
+ case "click": {
504
+ const isDouble = (a.clickCount ?? 1) > 1;
505
+ const verb = isDouble ? "dblclick" : "click";
506
+ const args = {};
507
+ if (a.button && a.button !== "left")
508
+ args.button = a.button;
509
+ const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
510
+ if (mods.length)
511
+ args.modifiers = mods;
512
+ return { toolName: "browser_click", code: `await ${root}.${locatorExpr}.${verb}();`, args };
513
+ }
514
+ case "hover":
515
+ return { toolName: "browser_hover", code: `await ${root}.${locatorExpr}.hover();`, args: {} };
516
+ case "fill":
517
+ return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.fill(${sq(a.text ?? "")});`, args: { text: a.text ?? "" } };
518
+ case "pressSequentially":
519
+ return { toolName: "browser_type", code: `await ${root}.${locatorExpr}.pressSequentially(${sq(a.text ?? "")});`, args: { text: a.text ?? "", slowly: true } };
520
+ case "press": {
521
+ const args = { key: a.key };
522
+ const mods = (0, import_loadTraceTool.decodeModifiers)(a.modifiers);
523
+ if (mods.length)
524
+ args.modifiers = mods;
525
+ return { toolName: "browser_press_key", code: "", args };
526
+ }
527
+ case "assertApiRequest":
528
+ return { toolName: "browser_assert_api_request", code: "", args: {} };
529
+ case "waitForTimeout":
530
+ return { toolName: "browser_wait_for", code: "", args: { time: (a.duration ?? 0) / 1e3 } };
531
+ case "check":
532
+ return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.check();`, args: { checked: true } };
533
+ case "uncheck":
534
+ return { toolName: "browser_set_checked", code: `await ${root}.${locatorExpr}.uncheck();`, args: { checked: false } };
535
+ case "select":
536
+ return { toolName: "browser_select_option", code: `await ${root}.${locatorExpr}.selectOption(${sq((a.options ?? [])[0] ?? "")});`, args: { values: a.options ?? [] } };
537
+ case "waitForSelector": {
538
+ const sel = a.selector ?? "";
539
+ const m = /^text=(.*)$/.exec(sel);
540
+ if (!m)
541
+ return null;
542
+ return a.state === "hidden" ? { toolName: "browser_wait_for", code: "", args: { textGone: m[1] } } : { toolName: "browser_wait_for", code: "", args: { text: m[1] } };
543
+ }
544
+ case "dragAndDrop":
545
+ case "dragTo": {
546
+ const srcSel = a.source ?? a.selector;
547
+ const tgtSel = a.target ?? a.targetSelector;
548
+ if (!srcSel || !tgtSel)
549
+ return null;
550
+ const srcExpr = (0, import_utils.asLocator)("javascript", srcSel);
551
+ const tgtExpr = (0, import_utils.asLocator)("javascript", tgtSel);
552
+ return { toolName: "browser_drag", code: `await page.${srcExpr}.dragTo(page.${tgtExpr});`, args: {} };
553
+ }
554
+ // Assertions are encoded the same way _handleAssert encodes them, so
555
+ // assertActionToJsonl in skyRampExport parses them identically. That
556
+ // parser dispatches on toolName === 'browser_assert' and keys on args.type.
557
+ // Assertions pass selector/expected as structured args (unambiguous even
558
+ // when the value contains ':'). assertActionToJsonl reads these directly;
559
+ // the legacy colon-encoded code string is kept only as a human-readable
560
+ // breadcrumb / backward-compat fallback.
561
+ case "assertText":
562
+ return { toolName: "browser_assert", code: `assertText:${a.selector}:${a.text}:${a.substring ?? true}`, args: { type: "text", selector: a.selector, expected: a.text, substring: a.substring ?? true } };
563
+ case "assertValue":
564
+ return { toolName: "browser_assert", code: `assertValue:${a.selector}:${a.value}`, args: { type: "value", selector: a.selector, expected: a.value } };
565
+ case "assertChecked":
566
+ return { toolName: "browser_assert", code: `assertChecked:${a.selector}:${!!a.checked}`, args: { type: "checked", selector: a.selector, checked: !!a.checked } };
567
+ case "assertVisible":
568
+ return { toolName: "browser_assert", code: `assertVisible:${a.selector}`, args: { type: "visible", selector: a.selector } };
569
+ default:
570
+ return null;
571
+ }
572
+ }
573
+ /**
574
+ * Build the normalized JSONL args for a re-seeded coordinate mouse action
575
+ * (mouse.move/down/up/wheel), following the MouseJsonlAction contract in
576
+ * common/mouseActions.ts. Only known fields are emitted (so stray fields from
577
+ * the loaded trace line are not forwarded), wheel deltas/modifiers default to
578
+ * 0, and a move/wheel missing its position is rejected (returns null) so the
579
+ * caller skips it rather than exporting an invalid shape for the Go consumer.
580
+ */
581
+ _seedMouseActionArgs(a) {
582
+ switch (a.name) {
583
+ case "mouse.move": {
584
+ if (!a.position)
585
+ return null;
586
+ const args = { name: "mouse.move", position: a.position };
587
+ if (a.steps !== void 0)
588
+ args.steps = a.steps;
589
+ return args;
590
+ }
591
+ case "mouse.down":
592
+ case "mouse.up":
593
+ return a.button && a.button !== "left" ? { name: a.name, button: a.button } : { name: a.name };
594
+ case "mouse.wheel":
595
+ if (!a.position)
596
+ return null;
597
+ return { name: "mouse.wheel", position: a.position, deltaX: a.deltaX ?? 0, deltaY: a.deltaY ?? 0, modifiers: a.modifiers ?? 0 };
598
+ default:
599
+ return null;
600
+ }
601
+ }
316
602
  /**
317
603
  * Handle browser_select_option with automatic fallback for custom dropdowns.
318
604
  * 1. Try native selectOption first.
@@ -437,14 +723,26 @@ Could not extract selector from hover result.` }], isError: true };
437
723
  }
438
724
  const locatorExpr = locatorMatch[1].trim();
439
725
  let parsed = this._codeToLocator(locatorExpr);
440
- if (!parsed) {
726
+ const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
727
+ if (snapResult.isError) {
728
+ const errText = snapResult.content?.[0]?.type === "text" ? snapResult.content[0].text : "";
441
729
  return { content: [{ type: "text", text: `### Assertion Failed
442
- Could not parse locator: ${locatorExpr}` }], isError: true };
730
+ Could not capture page snapshot to verify the assertion. ${errText}` }], isError: true };
443
731
  }
444
- const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
445
732
  const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
446
733
  const snapLines = snapText.split("\n");
447
734
  const refLine = snapLines.find((l) => l.includes(`[ref=${params.ref}]`)) || "";
735
+ if (!parsed || parsed.locator.kind === "css") {
736
+ const fromSnapshot = this._extractLocatorForRef(refLine);
737
+ if (fromSnapshot) {
738
+ traceDebug(`Resolved assert selector from snapshot accessible name (hover code was ${parsed ? "brittle CSS" : "unparseable"}: ${locatorExpr})`);
739
+ parsed = fromSnapshot;
740
+ }
741
+ }
742
+ if (!parsed) {
743
+ return { content: [{ type: "text", text: `### Assertion Failed
744
+ Could not parse locator: ${locatorExpr}` }], isError: true };
745
+ }
448
746
  const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
449
747
  const elementText = textMatch?.[1] || "";
450
748
  const valueMatch = refLine.match(/\]:\s*(.+)$/);
@@ -473,7 +771,10 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
473
771
  const assertName = params.type === "value" ? "assertValue" : "assertText";
474
772
  this._trackedActions.push({
475
773
  toolName: "browser_assert",
476
- args: { type: params.type, ref: params.ref, expected: params.expected },
774
+ // Pass selector/expected as structured args so assertActionToJsonl can
775
+ // decode unambiguously (the colon-encoded code below corrupts values
776
+ // that contain ':', e.g. URLs). The code string is a fallback only.
777
+ args: { type: params.type, ref: params.ref, selector: parsed.selector, expected: params.expected, ...params.type === "text" ? { substring: params.substring } : {} },
477
778
  code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
478
779
  timestamp
479
780
  });
@@ -519,6 +820,13 @@ ${details}` }]
519
820
  if (ariaRefMatch) {
520
821
  return null;
521
822
  }
823
+ const cssMatch = expr.match(/locator\(\s*['"]([#.][^'"]+)['"]\s*\)/);
824
+ if (cssMatch) {
825
+ return {
826
+ selector: cssMatch[1],
827
+ locator: { kind: "css", body: cssMatch[1], options: {} }
828
+ };
829
+ }
522
830
  return null;
523
831
  }
524
832
  /**
@@ -226,7 +226,12 @@ function trackedActionToJsonl(action, pageGuid, timestamp) {
226
226
  return null;
227
227
  }
228
228
  if (toolName === "browser_press_key")
229
- return JSON.stringify({ name: "press", key: args.key, selector: "", ...base });
229
+ return JSON.stringify({ name: "press", key: args.key, modifiers: modifiersArrayToMask(args.modifiers), selector: "", ...base });
230
+ if (toolName === "browser_mouse_action") {
231
+ if (!args.name)
232
+ return null;
233
+ return JSON.stringify({ ...args, ...base });
234
+ }
230
235
  if (!code)
231
236
  return null;
232
237
  const extracted = extractLocatorFromCode(code);
@@ -304,23 +309,34 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
304
309
  signals: [],
305
310
  timestamp: String(timestamp),
306
311
  pageGuid,
307
- pageAlias: DEFAULT_PAGE_ALIAS,
308
- framePath: DEFAULT_FRAME_PATH
312
+ pageAlias: action.pageAlias ?? DEFAULT_PAGE_ALIAS,
313
+ framePath: action.framePath ?? DEFAULT_FRAME_PATH
309
314
  };
310
- const parts = code.split(":");
311
- const selectorPart = parts[1] || "";
312
315
  let selector = "";
313
316
  let expected = "";
314
317
  let substring = true;
315
- if (assertType === "visible") {
316
- selector = parts.slice(1).join(":");
317
- } else if (assertType === "text") {
318
- substring = parts[parts.length - 1] === "true";
319
- expected = parts[parts.length - 2] || "";
320
- selector = parts.slice(1, parts.length - 2).join(":");
321
- } else if (assertType === "value") {
322
- expected = parts[parts.length - 1] || "";
323
- selector = parts.slice(1, parts.length - 1).join(":");
318
+ if (args.selector !== void 0) {
319
+ selector = args.selector;
320
+ if (assertType === "text") {
321
+ expected = args.expected ?? "";
322
+ substring = args.substring !== false;
323
+ } else if (assertType === "value") {
324
+ expected = args.expected ?? "";
325
+ } else if (assertType === "checked") {
326
+ expected = String(args.checked === true || args.expected === "true" || args.expected === true);
327
+ }
328
+ } else {
329
+ const parts = code.split(":");
330
+ if (assertType === "visible") {
331
+ selector = parts.slice(1).join(":");
332
+ } else if (assertType === "text") {
333
+ substring = parts[parts.length - 1] === "true";
334
+ expected = parts[parts.length - 2] || "";
335
+ selector = parts.slice(1, parts.length - 2).join(":");
336
+ } else if (assertType === "value" || assertType === "checked") {
337
+ expected = parts[parts.length - 1] || "";
338
+ selector = parts.slice(1, parts.length - 1).join(":");
339
+ }
324
340
  }
325
341
  const locator = selectorToLocator(selector);
326
342
  switch (assertType) {
@@ -330,6 +346,8 @@ function assertActionToJsonl(action, pageGuid, timestamp) {
330
346
  return JSON.stringify({ name: "assertVisible", selector, locator, ...base });
331
347
  case "value":
332
348
  return JSON.stringify({ name: "assertValue", selector, value: expected, locator, ...base });
349
+ case "checked":
350
+ return JSON.stringify({ name: "assertChecked", selector, checked: expected === "true", locator, ...base });
333
351
  default:
334
352
  return null;
335
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",