@skyramp/mcp 0.2.7 → 0.2.8

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.
Files changed (90) hide show
  1. package/build/index.js +5 -2
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
  3. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
  4. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
  5. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
  7. package/build/prompts/sut-setup/shared.d.ts +39 -0
  8. package/build/prompts/sut-setup/shared.js +132 -0
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +76 -2
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +91 -0
  13. package/build/prompts/testbot/testbot-prompts.d.ts +16 -1
  14. package/build/prompts/testbot/testbot-prompts.js +113 -22
  15. package/build/prompts/testbot/testbot-prompts.test.js +95 -45
  16. package/build/resources/sutSetupResource.d.ts +2 -0
  17. package/build/resources/sutSetupResource.js +45 -0
  18. package/build/resources/testbotResource.js +2 -2
  19. package/build/services/TestDiscoveryService.d.ts +2 -2
  20. package/build/services/TestDiscoveryService.js +22 -12
  21. package/build/services/TestExecutionService.d.ts +10 -1
  22. package/build/services/TestExecutionService.js +158 -26
  23. package/build/services/TestExecutionService.test.js +306 -0
  24. package/build/tools/executeSkyrampTestTool.js +26 -7
  25. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
  26. package/build/tools/submitReportTool.d.ts +10 -0
  27. package/build/tools/submitReportTool.js +106 -39
  28. package/build/tools/submitReportTool.test.js +296 -50
  29. package/build/tools/test-management/actionsTool.js +21 -7
  30. package/build/tools/test-management/analyzeChangesTool.d.ts +1 -1
  31. package/build/tools/test-management/analyzeChangesTool.js +106 -41
  32. package/build/tools/test-management/analyzeChangesTool.test.js +21 -41
  33. package/build/tools/test-management/analyzeTestHealthTool.js +16 -9
  34. package/build/tools/workspace/initializeWorkspaceTool.js +39 -22
  35. package/build/tools/workspace/serviceUpsert.d.ts +12 -0
  36. package/build/tools/workspace/serviceUpsert.js +23 -0
  37. package/build/tools/workspace/serviceUpsert.test.d.ts +1 -0
  38. package/build/tools/workspace/serviceUpsert.test.js +50 -0
  39. package/build/types/TestAnalysis.d.ts +2 -1
  40. package/build/types/TestExecution.d.ts +12 -2
  41. package/build/types/TestExecution.js +11 -1
  42. package/build/types/TestbotReport.d.ts +68 -0
  43. package/build/types/TestbotReport.js +1 -0
  44. package/build/types/index.d.ts +4 -0
  45. package/build/types/index.js +3 -0
  46. package/build/utils/AnalysisStateManager.d.ts +61 -1
  47. package/build/utils/AnalysisStateManager.js +144 -3
  48. package/build/utils/AnalysisStateManager.test.js +98 -0
  49. package/build/utils/docker.test.js +1 -1
  50. package/build/utils/routeParsers.d.ts +0 -10
  51. package/build/utils/routeParsers.js +31 -38
  52. package/build/utils/scenarioDrafting.d.ts +1 -1
  53. package/build/utils/scenarioDrafting.js +23 -1
  54. package/build/utils/utils.d.ts +7 -1
  55. package/build/utils/utils.js +10 -0
  56. package/build/utils/versions.d.ts +3 -3
  57. package/build/utils/versions.js +1 -1
  58. package/build/workspace/workspace.d.ts +10 -10
  59. package/build/workspace/workspace.js +7 -7
  60. package/build/workspace/workspace.test.js +6 -6
  61. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +31 -13
  62. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +88 -15
  63. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
  64. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  65. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +79 -19
  67. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/aliasRemapper.js +84 -0
  68. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +15 -4
  69. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-CPZKmjFR.js → codeMirrorModule-D0BjbCb7.js} +1 -1
  70. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +193 -0
  71. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-Cqp3cwEJ.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CYf9adZh.js} +79 -79
  74. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.B_7ywgmr.js} +1 -1
  75. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  76. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  77. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.u4_8VnCV.js} +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  80. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +129 -43
  81. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/aliasRemapper.ts +119 -0
  82. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +33 -10
  83. package/node_modules/playwright/package.json +1 -1
  84. package/package.json +10 -3
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
  89. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-CsgPR2wy.js +0 -193
  90. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.CP3Y8Zsb.js +0 -2
@@ -142,7 +142,7 @@ export function inferResourceRelationships(endpoints) {
142
142
  }
143
143
  return relationships;
144
144
  }
145
- export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}) {
145
+ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}, removedEndpoints = []) {
146
146
  const scenarios = [];
147
147
  const resourceGroups = new Map();
148
148
  for (const ep of endpoints) {
@@ -191,6 +191,28 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuth
191
191
  }
192
192
  scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType));
193
193
  scenarios.push(...draftAttackSurfaceExpansionScenarios(endpoints, options.changedEndpoints ?? [], wsAuthType, options.securityRelevantDiff ?? false));
194
+ // 404-guard scenarios for removed endpoints — absence assertions confirming
195
+ // the endpoint is gone. Single-step contract tests, no chaining needed.
196
+ for (const ep of removedEndpoints) {
197
+ scenarios.push({
198
+ scenarioName: `verify-removed-${ep.method.toLowerCase()}-${ep.path.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`,
199
+ description: `Verify ${ep.method} ${ep.path} returns 404 — endpoint was removed in this PR`,
200
+ category: "breaking_change",
201
+ priority: "high",
202
+ steps: [{
203
+ order: 1,
204
+ method: ep.method,
205
+ path: ep.path,
206
+ description: `${ep.method} ${ep.path} → 404 (endpoint removed)`,
207
+ interactionType: "error",
208
+ expectedStatusCode: 404,
209
+ }],
210
+ chainingKeys: [],
211
+ requiresAuth: false,
212
+ estimatedComplexity: "simple",
213
+ source: ScenarioSource.CodeInferred,
214
+ });
215
+ }
194
216
  return capScenarios(scenarios);
195
217
  }
196
218
  const MAX_TOTAL_SCENARIOS = 30;
@@ -1,4 +1,4 @@
1
- import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
1
+ import { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  export declare function readDiffFile(diffFilePath: string | undefined): string | undefined;
3
3
  export declare function toolError(message: string): CallToolResult;
4
4
  /**
@@ -7,6 +7,12 @@ export declare function toolError(message: string): CallToolResult;
7
7
  * `{ content: [{ type: "text", text }] }` boilerplate themselves.
8
8
  */
9
9
  export declare function toolText(text: string): CallToolResult;
10
+ /**
11
+ * Wraps a single text payload in the MCP resource-result shape so resource
12
+ * handlers don't have to construct
13
+ * `{ contents: [{ uri, mimeType, text }] }` boilerplate themselves.
14
+ */
15
+ export declare function resourceText(uri: string, text: string, mimeType?: string): ReadResourceResult;
10
16
  export declare const OUTPUT_DIR_FIELD_NAME = "outputDir";
11
17
  export declare const TRACE_OUTPUT_FILE_FIELD_NAME = "traceOutputFile";
12
18
  export declare const PLAYWRIGHT_OUTPUT_FILE_FIELD_NAME = "playwrightOutput";
@@ -28,6 +28,16 @@ export function toolText(text) {
28
28
  content: [{ type: "text", text }],
29
29
  };
30
30
  }
31
+ /**
32
+ * Wraps a single text payload in the MCP resource-result shape so resource
33
+ * handlers don't have to construct
34
+ * `{ contents: [{ uri, mimeType, text }] }` boilerplate themselves.
35
+ */
36
+ export function resourceText(uri, text, mimeType = "text/plain") {
37
+ return {
38
+ contents: [{ uri, mimeType, text }],
39
+ };
40
+ }
31
41
  export const OUTPUT_DIR_FIELD_NAME = "outputDir";
32
42
  export const TRACE_OUTPUT_FILE_FIELD_NAME = "traceOutputFile";
33
43
  export const PLAYWRIGHT_OUTPUT_FILE_FIELD_NAME = "playwrightOutput";
@@ -1,3 +1,3 @@
1
- export declare const SKYRAMP_IMAGE_VERSION = "v1.3.28";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.28";
3
- export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.28";
1
+ export declare const SKYRAMP_IMAGE_VERSION = "v1.3.29";
2
+ export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.29";
3
+ export declare const WORKER_DOCKER_IMAGE = "skyramp/worker:v1.3.29";
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.28";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.29";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
@@ -3,7 +3,7 @@ export declare const WORKSPACE_DIR = ".skyramp";
3
3
  export declare const WORKSPACE_FILENAME = "workspace.yml";
4
4
  export declare const serviceSchema: z.ZodObject<{
5
5
  serviceName: z.ZodString;
6
- repo: z.ZodOptional<z.ZodString>;
6
+ repository: z.ZodOptional<z.ZodString>;
7
7
  language: z.ZodOptional<z.ZodEnum<["python", "typescript", "javascript", "java"]>>;
8
8
  framework: z.ZodOptional<z.ZodEnum<["playwright", "pytest", "robot", "junit"]>>;
9
9
  testDirectory: z.ZodOptional<z.ZodString>;
@@ -49,7 +49,7 @@ export declare const serviceSchema: z.ZodObject<{
49
49
  serviceName: string;
50
50
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
51
51
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
52
- repo?: string | undefined;
52
+ repository?: string | undefined;
53
53
  testDirectory?: string | undefined;
54
54
  api?: {
55
55
  authHeader?: string | undefined;
@@ -69,7 +69,7 @@ export declare const serviceSchema: z.ZodObject<{
69
69
  serviceName: string;
70
70
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
71
71
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
72
- repo?: string | undefined;
72
+ repository?: string | undefined;
73
73
  testDirectory?: string | undefined;
74
74
  api?: {
75
75
  authHeader?: string | undefined;
@@ -118,7 +118,7 @@ export declare const workspaceConfigSchema: z.ZodObject<{
118
118
  }>>;
119
119
  services: z.ZodOptional<z.ZodArray<z.ZodObject<{
120
120
  serviceName: z.ZodString;
121
- repo: z.ZodOptional<z.ZodString>;
121
+ repository: z.ZodOptional<z.ZodString>;
122
122
  language: z.ZodOptional<z.ZodEnum<["python", "typescript", "javascript", "java"]>>;
123
123
  framework: z.ZodOptional<z.ZodEnum<["playwright", "pytest", "robot", "junit"]>>;
124
124
  testDirectory: z.ZodOptional<z.ZodString>;
@@ -164,7 +164,7 @@ export declare const workspaceConfigSchema: z.ZodObject<{
164
164
  serviceName: string;
165
165
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
166
166
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
167
- repo?: string | undefined;
167
+ repository?: string | undefined;
168
168
  testDirectory?: string | undefined;
169
169
  api?: {
170
170
  authHeader?: string | undefined;
@@ -184,7 +184,7 @@ export declare const workspaceConfigSchema: z.ZodObject<{
184
184
  serviceName: string;
185
185
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
186
186
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
187
- repo?: string | undefined;
187
+ repository?: string | undefined;
188
188
  testDirectory?: string | undefined;
189
189
  api?: {
190
190
  authHeader?: string | undefined;
@@ -217,7 +217,7 @@ export declare const workspaceConfigSchema: z.ZodObject<{
217
217
  serviceName: string;
218
218
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
219
219
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
220
- repo?: string | undefined;
220
+ repository?: string | undefined;
221
221
  testDirectory?: string | undefined;
222
222
  api?: {
223
223
  authHeader?: string | undefined;
@@ -250,7 +250,7 @@ export declare const workspaceConfigSchema: z.ZodObject<{
250
250
  serviceName: string;
251
251
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
252
252
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
253
- repo?: string | undefined;
253
+ repository?: string | undefined;
254
254
  testDirectory?: string | undefined;
255
255
  api?: {
256
256
  authHeader?: string | undefined;
@@ -291,7 +291,7 @@ export declare function validateWorkspaceConfig(config: unknown): z.SafeParseRet
291
291
  serviceName: string;
292
292
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
293
293
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
294
- repo?: string | undefined;
294
+ repository?: string | undefined;
295
295
  testDirectory?: string | undefined;
296
296
  api?: {
297
297
  authHeader?: string | undefined;
@@ -324,7 +324,7 @@ export declare function validateWorkspaceConfig(config: unknown): z.SafeParseRet
324
324
  serviceName: string;
325
325
  language?: "python" | "typescript" | "javascript" | "java" | undefined;
326
326
  framework?: "playwright" | "pytest" | "robot" | "junit" | undefined;
327
- repo?: string | undefined;
327
+ repository?: string | undefined;
328
328
  testDirectory?: string | undefined;
329
329
  api?: {
330
330
  authHeader?: string | undefined;
@@ -27,9 +27,9 @@ export const serviceSchema = z
27
27
  .object({
28
28
  serviceName: z.string(),
29
29
  // owner/repo this service belongs to (multi-repo workspaces). Absent → the
30
- // primary repo. Service identity is the composite (repo, serviceName), so the
31
- // same serviceName can appear once per repo without colliding.
32
- repo: z.string().optional(),
30
+ // primary repo. Service identity is the composite (repository, serviceName),
31
+ // so the same serviceName can appear once per repo without colliding.
32
+ repository: z.string().optional(),
33
33
  language: z.enum(["python", "typescript", "javascript", "java"]).optional(),
34
34
  framework: z.enum(["playwright", "pytest", "robot", "junit"]).optional(),
35
35
  testDirectory: z.string().optional(),
@@ -318,11 +318,11 @@ export class WorkspaceConfigManager {
318
318
  const validatedService = svcResult.data;
319
319
  const config = await this.read();
320
320
  const services = config.services || [];
321
- // Upsert keyed on the composite (repo, serviceName). Absent repo normalizes
322
- // to "" (primary) so a primary service and a same-named additional-repo
321
+ // Upsert keyed on the composite (repository, serviceName). Absent repository
322
+ // normalizes to "" (primary) so a primary service and a same-named related-repo
323
323
  // service are distinct entries.
324
- const repoKey = validatedService.repo || "";
325
- const idx = services.findIndex((s) => (s.repo || "") === repoKey &&
324
+ const repoKey = validatedService.repository || "";
325
+ const idx = services.findIndex((s) => (s.repository || "") === repoKey &&
326
326
  s.serviceName === validatedService.serviceName);
327
327
  if (idx >= 0) {
328
328
  services[idx] = validatedService;
@@ -6,10 +6,10 @@ import yaml from "js-yaml";
6
6
  import { serviceSchema, WorkspaceConfigManager, validateWorkspaceConfig, createDefaultConfig, WORKSPACE_DIR, WORKSPACE_FILENAME, } from "./workspace.js";
7
7
  describe("workspace module (ported from @skyramp/skyramp, SKYR-3794)", () => {
8
8
  describe("serviceSchema", () => {
9
- it("accepts an optional repo and rejects a non-string repo", () => {
10
- expect(serviceSchema.safeParse({ serviceName: "api", repo: "org/api" }).success).toBe(true);
9
+ it("accepts an optional repository and rejects a non-string repository", () => {
10
+ expect(serviceSchema.safeParse({ serviceName: "api", repository: "org/api" }).success).toBe(true);
11
11
  expect(serviceSchema.safeParse({ serviceName: "api" }).success).toBe(true);
12
- expect(serviceSchema.safeParse({ serviceName: "api", repo: 123 }).success).toBe(false);
12
+ expect(serviceSchema.safeParse({ serviceName: "api", repository: 123 }).success).toBe(false);
13
13
  });
14
14
  it("rejects unknown fields (strict)", () => {
15
15
  expect(serviceSchema.safeParse({ serviceName: "api", bogus: true }).success).toBe(false);
@@ -42,12 +42,12 @@ describe("workspace module (ported from @skyramp/skyramp, SKYR-3794)", () => {
42
42
  const mgr = new WorkspaceConfigManager(repoDir);
43
43
  await mgr.initialize();
44
44
  await mgr.addService({ serviceName: "svc", testDirectory: "tests/primary" });
45
- await mgr.addService({ serviceName: "svc", repo: "letsramp/api-insight", testDirectory: "tests/py" });
45
+ await mgr.addService({ serviceName: "svc", repository: "letsramp/api-insight", testDirectory: "tests/py" });
46
46
  await mgr.addService({ serviceName: "svc", testDirectory: "tests/primary-v2" }); // upsert primary
47
47
  const cfg = read();
48
48
  expect(cfg.services).toHaveLength(2);
49
- expect(cfg.services.find((s) => !s.repo).testDirectory).toBe("tests/primary-v2");
50
- expect(cfg.services.find((s) => s.repo === "letsramp/api-insight").testDirectory).toBe("tests/py");
49
+ expect(cfg.services.find((s) => !s.repository).testDirectory).toBe("tests/primary-v2");
50
+ expect(cfg.services.find((s) => s.repository === "letsramp/api-insight").testDirectory).toBe("tests/py");
51
51
  // repoName auto-detected from the git remote.
52
52
  expect(cfg.workspace.repoName).toBe("letsramp/primary");
53
53
  });
@@ -36,20 +36,23 @@ const loadTraceSchema = {
36
36
  name: "skyramp_load_trace",
37
37
  title: "Load and replay a Skyramp trace",
38
38
  description: [
39
- "Load a previously recorded Skyramp trace (.zip or .jsonl/.txt) and replay its actions against the live browser to restore state, then continue recording from that point.",
40
- "The replayed actions are merged into the current recording, so subsequent browser_* steps append to them and skyramp_export_zip writes a combined trace.",
41
- "STOPPING EARLY: when the user wants to stop partway through the loaded trace before continuing, set ONE of stopAtStep / stopAtUrl / stopBefore based on their prompt. Omit all three to replay the whole trace.",
42
- "- stopAtStep: replay steps 1..N then stop (N is 1-based, from the numbered step list).",
39
+ "Load a previously recorded Skyramp trace (.zip or .jsonl/.txt) and replay a range of its actions against the live browser to restore state, then continue recording from that point.",
40
+ "The replayed actions are merged into the current recording, so subsequent browser_* steps append to them and skyramp_export_zip writes a combined trace. You can record some steps FIRST, then load a trace to insert an already-recorded fragment at the current point.",
41
+ "STARTING LATER (skip a stale prefix): set startAtStep to begin replay partway in, skipping earlier steps. Use this when the live browser is already past the trace's opening steps - e.g. the login page changed, you recorded the NEW login manually, and now want to append only the still-valid post-login part of the old trace. Omit to start from step 1.",
42
+ "- startAtStep: begin replay at step N (1-based, from the numbered step list); steps 1..N-1 are skipped (neither replayed nor recorded).",
43
+ "STOPPING EARLY: when the user wants to stop partway through the loaded trace before continuing, set ONE of stopAtStep / stopAtUrl / stopBefore based on their prompt. Omit all three to replay to the end.",
44
+ "- stopAtStep: replay up to and including step N then stop (N is 1-based, from the numbered step list).",
43
45
  "- stopAtUrl: stop once the active page URL matches this glob/regex/substring (checked after each action).",
44
46
  '- stopBefore: stop right BEFORE the first action whose description contains this text (case-insensitive), e.g. "Checkout" to stop before clicking Checkout.',
45
- "If unsure where to stop, first call with dryRun:true to read the numbered step list, then call again with the chosen stop parameter."
47
+ "If unsure where to start/stop, first call with dryRun:true to read the numbered step list, then call again with the chosen start/stop parameters."
46
48
  ].join(" "),
47
49
  inputSchema: import_mcpBundle.z.object({
48
50
  path: import_mcpBundle.z.string().describe("Absolute path to the trace file (.zip, .jsonl, or .txt)."),
49
- stopAtStep: import_mcpBundle.z.number().int().positive().optional().describe("Replay steps 1..N (1-based), then stop before continuing."),
51
+ startAtStep: import_mcpBundle.z.number().int().positive().optional().describe("Begin replay at step N (1-based); skip steps 1..N-1. Use to skip a stale prefix (e.g. old login)."),
52
+ stopAtStep: import_mcpBundle.z.number().int().positive().optional().describe("Replay up to and including step N (1-based), then stop before continuing."),
50
53
  stopAtUrl: import_mcpBundle.z.string().optional().describe("Stop once the active page URL matches this glob/regex/substring."),
51
54
  stopBefore: import_mcpBundle.z.string().optional().describe("Stop before the first action whose description contains this text (case-insensitive)."),
52
- dryRun: import_mcpBundle.z.boolean().optional().describe("Parse and list the steps WITHOUT replaying. Use to choose a stop point."),
55
+ dryRun: import_mcpBundle.z.boolean().optional().describe("Parse and list the steps WITHOUT replaying. Use to choose start/stop points."),
53
56
  speed: import_mcpBundle.z.enum(["fast", "slow"]).optional().describe('Replay speed. "slow" adds a 1s delay between actions (default "fast").')
54
57
  }),
55
58
  // Replaying drives the live browser and mutates the recording, so this is an
@@ -134,17 +137,30 @@ function describeStopReason(result) {
134
137
  async function replayActions(actionsList, callbacks, params) {
135
138
  const delay = params.speed === "slow" ? 1e3 : 0;
136
139
  const stopBefore = params.stopBefore?.toLowerCase();
137
- let completedCount = 0;
138
- for (let i = 0; i < actionsList.length; i++) {
140
+ const startIndex = params.startAtStep ? Math.max(0, Math.min(params.startAtStep - 1, actionsList.length)) : 0;
141
+ let completedCount = startIndex;
142
+ for (let i = startIndex; i < actionsList.length; i++) {
139
143
  const action = actionsList[i];
140
144
  if (stopBefore && describeStep(action, i).toLowerCase().includes(stopBefore))
141
- return { completedCount, stopIndex: i, stopReason: "stopBefore" };
145
+ return { completedCount, startIndex, stopIndex: i, stopReason: "stopBefore" };
146
+ try {
147
+ callbacks.beforeAction?.(action);
148
+ } catch (e) {
149
+ return {
150
+ completedCount,
151
+ startIndex,
152
+ stopIndex: i,
153
+ stopReason: "error",
154
+ error: { message: e.message, actionName: action.action.name, stepIndex: i + 1 }
155
+ };
156
+ }
142
157
  const alias = action.frame.pageAlias || "page";
143
158
  const page = callbacks.pageForAlias(alias);
144
159
  if (!METADATA_ONLY_ACTIONS.has(action.action.name)) {
145
160
  if (!page) {
146
161
  return {
147
162
  completedCount,
163
+ startIndex,
148
164
  stopIndex: i,
149
165
  stopReason: "error",
150
166
  error: { message: `No live page for alias "${alias}"`, actionName: action.action.name, stepIndex: i + 1 }
@@ -152,9 +168,11 @@ async function replayActions(actionsList, callbacks, params) {
152
168
  }
153
169
  try {
154
170
  await performClientAction(page, action);
171
+ await callbacks.afterAction?.(action);
155
172
  } catch (e) {
156
173
  return {
157
174
  completedCount,
175
+ startIndex,
158
176
  stopIndex: i,
159
177
  stopReason: "error",
160
178
  error: { message: e.message, actionName: action.action.name, stepIndex: i + 1 }
@@ -163,16 +181,16 @@ async function replayActions(actionsList, callbacks, params) {
163
181
  }
164
182
  completedCount = i + 1;
165
183
  if (params.stopAtStep !== void 0 && completedCount >= params.stopAtStep)
166
- return { completedCount, stopIndex: i + 1, stopReason: "stopAtStep" };
184
+ return { completedCount, startIndex, stopIndex: i + 1, stopReason: "stopAtStep" };
167
185
  if (params.stopAtUrl) {
168
186
  const currentUrl = page?.url() ?? "";
169
187
  if (urlMatchesPattern(currentUrl, params.stopAtUrl))
170
- return { completedCount, stopIndex: i + 1, stopReason: "stopAtUrl" };
188
+ return { completedCount, startIndex, stopIndex: i + 1, stopReason: "stopAtUrl" };
171
189
  }
172
190
  if (delay)
173
191
  await new Promise((resolve) => setTimeout(resolve, delay));
174
192
  }
175
- return { completedCount, stopIndex: actionsList.length, stopReason: "done" };
193
+ return { completedCount, startIndex, stopIndex: actionsList.length, stopReason: "done" };
176
194
  }
177
195
  function urlMatchesPattern(currentUrl, pattern) {
178
196
  const regexLiteral = /^\/(.*)\/([a-z]*)$/i.exec(pattern);
@@ -111,6 +111,11 @@ class TraceRecordingBackend {
111
111
  },
112
112
  contextOptions: {
113
113
  viewport: import_skyRampExport.RECORDING_VIEWPORT,
114
+ // Mirror `playwright codegen --load-storage`: seed the recording
115
+ // browser with a saved session so it records against an authenticated
116
+ // app. Same source (PLAYWRIGHT_MCP_STORAGE_STATE) that buildJsonlContent
117
+ // stamps into the export header, so load + emit stay consistent.
118
+ ...process.env.PLAYWRIGHT_MCP_STORAGE_STATE?.trim() ? { storageState: process.env.PLAYWRIGHT_MCP_STORAGE_STATE.trim() } : {},
114
119
  recordHar: { path: this._harPath, mode: "minimal" },
115
120
  ignoreHTTPSErrors: true,
116
121
  serviceWorkers: "block"
@@ -520,6 +525,9 @@ Failed to load trace from ${params.path}: ${e.message}` }], isError: true };
520
525
  if (!loadedActions.length)
521
526
  return { content: [{ type: "text", text: `### Error
522
527
  No actions found in ${params.path}.` }], isError: true };
528
+ if (params.startAtStep !== void 0 && params.stopAtStep !== void 0 && params.startAtStep > params.stopAtStep)
529
+ return { content: [{ type: "text", text: `### Error
530
+ startAtStep (${params.startAtStep}) is after stopAtStep (${params.stopAtStep}).` }], isError: true };
523
531
  if (params.dryRun) {
524
532
  const stepList = loadedActions.map((a, i) => (0, import_loadTraceTool.describeStep)(a, i)).join("\n");
525
533
  return {
@@ -531,7 +539,7 @@ ${loadedActions.length} steps in ${params.path}. Nothing was replayed.
531
539
  Steps:
532
540
  ${stepList}
533
541
 
534
- Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none of them to replay all) to replay.`
542
+ Call skyramp_load_trace again to replay: set startAtStep to skip a stale prefix (e.g. an old login) and/or one of stopAtStep / stopAtUrl / stopBefore to stop early. Omit all to replay the whole trace.`
535
543
  }]
536
544
  };
537
545
  }
@@ -539,17 +547,83 @@ Call skyramp_load_trace again with stopAtStep / stopAtUrl / stopBefore (or none
539
547
  await this._browserBackend.context.ensureTab();
540
548
  } catch {
541
549
  }
550
+ const startIndex = params.startAtStep ? Math.max(0, Math.min(params.startAtStep - 1, loadedActions.length)) : 0;
551
+ let guardEnd = loadedActions.length;
552
+ if (params.stopAtStep) {
553
+ guardEnd = Math.min(guardEnd, params.stopAtStep);
554
+ } else if (params.stopBefore) {
555
+ const needle = params.stopBefore.toLowerCase();
556
+ const matchIndex = loadedActions.findIndex((a, i) => i >= startIndex && (0, import_loadTraceTool.describeStep)(a, i).toLowerCase().includes(needle));
557
+ if (matchIndex !== -1)
558
+ guardEnd = matchIndex;
559
+ }
560
+ const replayRange = loadedActions.slice(startIndex, guardEnd);
561
+ const primaryAlias = replayRange[0]?.frame.pageAlias || "page";
562
+ {
563
+ const available = /* @__PURE__ */ new Set([primaryAlias]);
564
+ for (const a of replayRange) {
565
+ if (!available.has(a.frame.pageAlias))
566
+ return { content: [{ type: "text", text: `### Error
567
+ The selected range acts on tab "${a.frame.pageAlias}" but the step that opens that tab is outside the range (skipped by startAtStep). Lower startAtStep to include the opening step, or pick a range that stays on a single tab.` }], isError: true };
568
+ for (const s of a.action.signals) {
569
+ if (s.name === "popup")
570
+ available.add(s.popupAlias);
571
+ }
572
+ }
573
+ }
574
+ const liveTabs = () => (this._browserBackend.context?.tabs() ?? []).map((t) => t.page);
575
+ const seedPage = this._browserBackend.context?.currentTab()?.page ?? liveTabs()[0];
576
+ const remap = /* @__PURE__ */ new Map();
577
+ remap.set(primaryAlias, { page: seedPage, liveAlias: this._currentPageAlias });
578
+ const claimedTabs = /* @__PURE__ */ new Set();
579
+ if (seedPage)
580
+ claimedTabs.add(seedPage);
581
+ let tabsBefore = [];
542
582
  let result;
543
583
  try {
544
- result = await (0, import_loadTraceTool.replayActions)(loadedActions, { pageForAlias: (alias) => this._pageForAlias(alias) }, params);
584
+ result = await (0, import_loadTraceTool.replayActions)(loadedActions, {
585
+ pageForAlias: (alias) => remap.get(alias)?.page ?? this._pageForAlias(alias),
586
+ beforeAction: () => {
587
+ tabsBefore = liveTabs();
588
+ },
589
+ afterAction: async (action) => {
590
+ for (const signal of action.action.signals) {
591
+ if (signal.name !== "popup")
592
+ continue;
593
+ let fresh;
594
+ for (let k = 0; k < 100 && !fresh; k++) {
595
+ fresh = liveTabs().find((p) => !tabsBefore.includes(p) && !claimedTabs.has(p));
596
+ if (!fresh)
597
+ await new Promise((r) => setTimeout(r, 50));
598
+ }
599
+ if (!fresh) {
600
+ throw new Error(`Expected a popup ("${signal.popupAlias}") to open after this action, but no new tab appeared \u2014 the fragment has drifted from the live page.`);
601
+ }
602
+ claimedTabs.add(fresh);
603
+ remap.set(signal.popupAlias, { page: fresh, liveAlias: this._currentPageAlias });
604
+ }
605
+ }
606
+ }, params);
545
607
  } catch (e) {
546
608
  return { content: [{ type: "text", text: `### Error
547
609
  Replay failed: ${e.message}` }], isError: true };
548
610
  }
549
- for (let i = 0; i < result.stopIndex; i++)
550
- this._seedTrackedAction(loadedActions[i]);
551
- if (result.stopIndex > 0)
611
+ for (let i = result.startIndex; i < result.stopIndex; i++) {
612
+ const a = loadedActions[i];
613
+ const liveAlias = remap.get(a.frame.pageAlias)?.liveAlias;
614
+ if (liveAlias && liveAlias !== a.frame.pageAlias)
615
+ a.frame = { ...a.frame, pageAlias: liveAlias };
616
+ const before = this._trackedActions.length;
617
+ this._seedTrackedAction(a);
618
+ const popupSignal = a.action.signals.find((s) => s.name === "popup");
619
+ if (popupSignal && this._trackedActions.length > before)
620
+ this._trackedActions[this._trackedActions.length - 1].popupAlias = remap.get(popupSignal.popupAlias)?.liveAlias ?? popupSignal.popupAlias;
621
+ }
622
+ if (result.stopIndex > result.startIndex)
552
623
  this._currentPageAlias = loadedActions[result.stopIndex - 1].frame.pageAlias || "page";
624
+ const replayedCount = Math.max(0, result.stopIndex - result.startIndex);
625
+ const clamped = params.startAtStep !== void 0 && params.startAtStep !== result.startIndex + 1;
626
+ const skippedNote = result.startIndex > 0 ? ` Skipped the first ${result.startIndex} step${result.startIndex === 1 ? "" : "s"} (startAtStep ${params.startAtStep}${clamped ? `, clamped \u2014 trace has ${loadedActions.length} steps` : ""}).` : "";
553
627
  const remaining = (0, import_loadTraceTool.listStepsFrom)(loadedActions, result.stopIndex);
554
628
  const stoppedNote = (0, import_loadTraceTool.describeStopReason)(result);
555
629
  const errorNote = result.error ? `
@@ -560,7 +634,7 @@ You can continue recording from this point or fix the issue.` : "";
560
634
  content: [{
561
635
  type: "text",
562
636
  text: `### Trace replayed
563
- ${result.completedCount} of ${loadedActions.length} steps replayed from ${params.path}. ${stoppedNote}${errorNote}
637
+ ${replayedCount} of ${loadedActions.length} steps replayed from ${params.path}.${skippedNote} ${stoppedNote}${errorNote}
564
638
 
565
639
  Remaining steps (NOT replayed):
566
640
  ${remaining}
@@ -1658,10 +1732,9 @@ Cell row ${params.row}, column ${params.column} has "${actual}", expected "${par
1658
1732
  const url = page.url();
1659
1733
  if (url.startsWith("chrome-extension://") || url.startsWith("about:") || url.startsWith("file://"))
1660
1734
  return;
1661
- const alreadyEnabled = await page.evaluate(() => {
1662
- const host = document.querySelector("flt-semantics-host");
1663
- return host && host.children.length > 0;
1664
- }).catch(() => false);
1735
+ const alreadyEnabled = await page.evaluate(
1736
+ () => document.querySelector("flt-semantics-placeholder") === null && document.querySelector("flt-semantics-host") !== null
1737
+ ).catch(() => false);
1665
1738
  if (alreadyEnabled) {
1666
1739
  traceDebug("Flutter accessibility already enabled");
1667
1740
  return;
@@ -1689,14 +1762,14 @@ Cell row ${params.row}, column ${params.column} has "${actual}", expected "${par
1689
1762
  traceDebug("Flutter detected but placeholder click failed");
1690
1763
  return;
1691
1764
  }
1692
- const enabled = await page.waitForFunction(() => {
1693
- const host = document.querySelector("flt-semantics-host");
1694
- return host && host.children.length > 0;
1695
- }, { timeout: 5e3 }).then(() => true).catch(() => false);
1765
+ const enabled = await page.waitForFunction(
1766
+ () => document.querySelector("flt-semantics-placeholder") === null,
1767
+ { timeout: 1e4 }
1768
+ ).then(() => true).catch(() => false);
1696
1769
  if (enabled)
1697
1770
  traceDebug("Flutter accessibility mode enabled");
1698
1771
  else
1699
- traceDebug("Flutter placeholder clicked but semantics host did not appear");
1772
+ traceDebug("Flutter placeholder clicked but semantics not confirmed active");
1700
1773
  }
1701
1774
  /** Clean up temp directory and optionally recreate it for next session. */
1702
1775
  _cleanupTempDir(recreate = false) {
@@ -55,12 +55,13 @@ function modifiersArrayToMask(modifiers) {
55
55
  return mask;
56
56
  }
57
57
  const RECORDING_VIEWPORT = { width: 1280, height: 900 };
58
- function jsonlHeader(browserName, harPath, viewport) {
58
+ function jsonlHeader(browserName, harPath, viewport, storageState) {
59
59
  return JSON.stringify({
60
60
  browserName,
61
61
  launchOptions: { headless: false },
62
62
  contextOptions: {
63
63
  viewport,
64
+ ...storageState ? { storageState } : {},
64
65
  ignoreHTTPSErrors: true,
65
66
  recordHar: { path: harPath, mode: "minimal" },
66
67
  serviceWorkers: "block"
@@ -623,7 +624,7 @@ function mouseActionToJsonl(action, pageGuid, baseTs, alias) {
623
624
  return [];
624
625
  }
625
626
  }
626
- function buildJsonlContent(actions, browserName, harPath, viewport = RECORDING_VIEWPORT) {
627
+ function buildJsonlContent(actions, browserName, harPath, viewport = RECORDING_VIEWPORT, storageState = process.env.PLAYWRIGHT_MCP_STORAGE_STATE?.trim() || void 0) {
627
628
  normalizePrimaryPageAlias(actions);
628
629
  const deduplicated = removeRedundantClicks(deduplicateRetries(actions));
629
630
  const startTime = deduplicated[0]?.timestamp ?? Date.now();
@@ -635,7 +636,7 @@ function buildJsonlContent(actions, browserName, harPath, viewport = RECORDING_V
635
636
  };
636
637
  const mainGuid = getPageGuid(DEFAULT_PAGE_ALIAS);
637
638
  const lines = [
638
- jsonlHeader(browserName, harPath, viewport),
639
+ jsonlHeader(browserName, harPath, viewport, storageState),
639
640
  JSON.stringify({ name: "openPage", url: "about:blank", signals: [], timestamp: String(startTime - 1e3), pageGuid: mainGuid, pageAlias: DEFAULT_PAGE_ALIAS, framePath: DEFAULT_FRAME_PATH })
640
641
  ];
641
642
  let actionCount = 0;