@skyramp/mcp 0.3.3 → 0.3.4

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 (55) hide show
  1. package/build/playwright/registerPlaywrightTools.js +42 -1
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
  3. package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
  5. package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
  6. package/build/prompts/test-recommendation/recommendationSections.js +5 -5
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
  8. package/build/prompts/testbot/testbot-prompts.js +6 -13
  9. package/build/recommendation/discriminators.d.ts +7 -1
  10. package/build/recommendation/discriminators.js +16 -3
  11. package/build/resources/testbotResource.js +0 -1
  12. package/build/services/ScenarioGenerationService.js +5 -2
  13. package/build/services/TestExecutionService.js +25 -1
  14. package/build/services/TestGenerationService.js +24 -9
  15. package/build/services/containerEnv.d.ts +12 -1
  16. package/build/services/containerEnv.js +94 -1
  17. package/build/tools/executeSkyrampTestTool.d.ts +9 -0
  18. package/build/tools/executeSkyrampTestTool.js +20 -6
  19. package/build/tools/execution-video-state.d.ts +21 -0
  20. package/build/tools/execution-video-state.js +51 -0
  21. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
  22. package/build/tools/generate-tests/planGuard.d.ts +5 -5
  23. package/build/tools/generate-tests/planGuard.js +5 -17
  24. package/build/tools/submitReportTool.d.ts +83 -10
  25. package/build/tools/submitReportTool.js +169 -28
  26. package/build/tools/test-management/actionsTool.js +40 -39
  27. package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
  28. package/build/tools/test-management/analyzeChangesTool.js +37 -33
  29. package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
  30. package/build/tools/test-management/registerTestPlanTool.js +113 -31
  31. package/build/types/TestExecution.d.ts +14 -0
  32. package/build/types/TestTypes.js +3 -2
  33. package/build/types/TestbotPromptOptions.d.ts +0 -1
  34. package/build/types/TestbotReport.d.ts +10 -0
  35. package/build/types/TestbotReport.js +10 -1
  36. package/build/types/index.d.ts +1 -0
  37. package/build/types/index.js +1 -0
  38. package/build/utils/AnalysisStateManager.d.ts +36 -2
  39. package/build/utils/AnalysisStateManager.js +34 -13
  40. package/build/utils/scenarioDrafting.js +7 -1
  41. package/build/utils/skyrampMdContent.d.ts +1 -1
  42. package/build/utils/skyrampMdContent.js +1 -1
  43. package/build/utils/urlPath.d.ts +37 -0
  44. package/build/utils/urlPath.js +55 -0
  45. package/build/utils/utils.d.ts +45 -0
  46. package/build/utils/utils.js +50 -0
  47. package/build/utils/versions.d.ts +3 -3
  48. package/build/utils/versions.js +1 -1
  49. package/build/utils/workspaceAuth.d.ts +15 -15
  50. package/build/utils/workspaceAuth.js +32 -17
  51. package/build/workspace/queryParamResolution.d.ts +93 -0
  52. package/build/workspace/queryParamResolution.js +201 -0
  53. package/build/workspace/workspace.d.ts +104 -0
  54. package/build/workspace/workspace.js +24 -0
  55. package/package.json +3 -2
@@ -62,7 +62,7 @@ export function registerAnalyzeTestHealthTool(server) {
62
62
  inputSchema: {
63
63
  stateFile: z
64
64
  .string()
65
- .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
65
+ .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass testbot-result.txt here."),
66
66
  blueprintCaptured: z
67
67
  .boolean()
68
68
  .optional()
@@ -88,10 +88,10 @@ export function registerAnalyzeTestHealthTool(server) {
88
88
  return toolError(`State file is empty or invalid: ${args.stateFile}${args.repository ? ` (no section for repository ${args.repository})` : ""}. Call skyramp_analyze_changes first to generate a valid state file.`);
89
89
  }
90
90
  if (!repositoryPath || typeof repositoryPath !== "string") {
91
- return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
91
+ return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
92
92
  }
93
93
  if (!Array.isArray(stateData.existingTests)) {
94
- return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt.`);
94
+ return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt.`);
95
95
  }
96
96
  // External tests: UPDATE only — REGENERATE/DELETE are report-only (enforced in
97
97
  // skyramp_actions). Already scoped to relevant ones by discovery.
@@ -128,38 +128,57 @@ function toDraftedScenario(input) {
128
128
  testType: input.testType,
129
129
  };
130
130
  }
131
- /** Build the agent-submitted candidates, running discriminator verification
132
- * against the persisted diff text. Failed claims demote (never reject). */
133
- function buildAgentCandidates(candidates, diffText) {
134
- const demotions = [];
135
- const built = candidates.map((input) => {
131
+ /** Build the agent-submitted candidates. Discriminator claims are verified
132
+ * later, by {@link applyDiscriminatorClaims}, once the merge has settled which
133
+ * scenario each name resolves to. */
134
+ function buildAgentCandidates(candidates) {
135
+ return candidates.map((input) => {
136
136
  const scenario = toDraftedScenario(input);
137
- const candidateId = computeCandidateId(scenario);
138
- let verifiedDiscriminator;
139
- if (input.discriminator) {
140
- const claim = input.discriminator;
141
- const result = validateDiscriminator(scenario, claim, diffText);
142
- if (result.verified) {
143
- verifiedDiscriminator = claim.kind;
144
- }
145
- else {
146
- demotions.push({
147
- candidateId,
148
- reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
149
- });
150
- }
151
- }
152
- const candidate = {
137
+ return {
153
138
  scenario,
154
139
  priority: derivePriorityTier(scenario),
155
140
  novelty: Novelty.NEW,
156
141
  source: CandidateSource.AGENT,
157
- candidateId,
158
- ...(verifiedDiscriminator ? { verifiedDiscriminator } : {}),
142
+ candidateId: computeCandidateId(scenario),
159
143
  };
160
- return candidate;
161
144
  });
162
- return { candidates: built, demotions };
145
+ }
146
+ /**
147
+ * Verify each declared discriminator against the candidate that actually won
148
+ * the merge, and mark it. Failed claims demote (never reject).
149
+ *
150
+ * Runs AFTER the merge, not while building the agent's candidates: a server
151
+ * pre-seeded candidate wins the name, so verifying the agent's copy first
152
+ * attaches the result to a candidate that is about to be discarded — silently
153
+ * stripping the verification. Verification is against the WINNER's
154
+ * steps, so a claim that only held for the agent's rewrite is correctly refused
155
+ * rather than carried across.
156
+ *
157
+ * The marker is cleared before the current claim is applied. Each call replaces
158
+ * the agent's previous submission (see the `candidates` schema), and
159
+ * recoverServerCandidates restores whatever the LAST plan recorded — so keeping
160
+ * it would let a boost earned by an earlier registration outlive the claim that
161
+ * earned it, on a submission that no longer makes that claim or whose new claim
162
+ * fails.
163
+ */
164
+ function applyDiscriminatorClaims(candidates, claimsByKey, diffText, reposWithoutDiff = []) {
165
+ const demotions = [];
166
+ const marked = candidates.map((candidate) => {
167
+ const { verifiedDiscriminator: _stale, ...unmarked } = candidate;
168
+ const claim = claimsByKey.get(scenarioMergeKey(candidate.scenario.scenarioName) || candidate.candidateId);
169
+ if (!claim)
170
+ return unmarked;
171
+ const result = validateDiscriminator(candidate.scenario, claim, diffText, reposWithoutDiff);
172
+ if (result.verified) {
173
+ return { ...unmarked, verifiedDiscriminator: claim.kind };
174
+ }
175
+ demotions.push({
176
+ candidateId: candidate.candidateId,
177
+ reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
178
+ });
179
+ return unmarked;
180
+ });
181
+ return { candidates: marked, demotions };
163
182
  }
164
183
  /** Recover full Candidates for pre-seeded ("server") plan items by matching
165
184
  * candidateId back to the analyzed scenarios (repositoryAnalysis.scenarios) —
@@ -190,15 +209,49 @@ function recoverServerCandidates(priorPlan, allScenarios) {
190
209
  }
191
210
  return recovered;
192
211
  }
193
- function resolveBudgetContext(stateData) {
212
+ /** Whether a repo section's persisted diff classification contains any new,
213
+ * modified, or removed endpoint — the same signal `computeScoredCandidates`
214
+ * derives `hasApiChanges` from at analyze time. */
215
+ function sectionHasApiChanges(data) {
216
+ const diff = data?.repositoryAnalysis?.diff;
217
+ return ((diff?.newEndpoints?.length ?? 0) > 0 ||
218
+ (diff?.modifiedEndpoints?.length ?? 0) > 0 ||
219
+ (diff?.removedEndpoints?.length ?? 0) > 0);
220
+ }
221
+ function resolveBudgetContext(stateData, fullState) {
194
222
  const pbc = stateData.planBudgetContext;
195
- return {
223
+ const base = {
196
224
  maxGenerate: pbc?.maxGenerate ?? MAX_TESTS_TO_GENERATE,
197
225
  maxTotal: pbc?.maxTotal ?? MAX_RECOMMENDATIONS,
198
226
  isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
199
227
  hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
200
228
  externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
201
229
  };
230
+ const relatedSections = Object.values(fullState?.relatedRepos ?? {}).map((section) => section.data);
231
+ if (relatedSections.length === 0)
232
+ return base;
233
+ // SKYR-4204: isUIOnlyPR/hasFrontendChanges are per-SECTION facts — each
234
+ // analyze call computes them from its own repo's diff. The approved plan is
235
+ // one run-wide decision, so a frontend-only PRIMARY diff must not put the
236
+ // budgeter on its all-UI branch (backendGenerateCount() = 0) while a related
237
+ // repo carries endpoint changes; that pinned GENERATE to UI placeholders and
238
+ // left every API candidate in ADDITIONAL regardless of maxGenerate. Derive
239
+ // the flags across ALL sections. A related repo checked out on its default
240
+ // branch classifies no endpoint changes, so it leaves a UI-only run UI-only.
241
+ // Budget numbers come from the root (primary) section: the testbot prompt
242
+ // passes topN/maxGenerate only to the primary analyze call, so related
243
+ // sections carry defaults.
244
+ const sections = [fullState ?? undefined, ...relatedSections];
245
+ const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
246
+ const hasApiChanges = sections.some(sectionHasApiChanges);
247
+ const rootPbc = fullState?.planBudgetContext;
248
+ return {
249
+ maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
250
+ maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
251
+ isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
252
+ hasFrontendChanges,
253
+ externalCoverage: base.externalCoverage,
254
+ };
202
255
  }
203
256
  function describeGenerationCall(item) {
204
257
  switch (item.testType) {
@@ -316,9 +369,27 @@ export function registerRegisterTestPlanTool(server) {
316
369
  errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
317
370
  return errorResult;
318
371
  }
319
- const diffText = stateData.diffText ?? "";
372
+ // SKYR-4203: one register call carries candidates for EVERY repo of the
373
+ // run, but `stateData` is a single repo's section — and the candidate
374
+ // schema has no per-candidate repository, so a related repo's anchors
375
+ // were unverifiable by construction. Verify anchors (and rank diff-hunk
376
+ // proximity) against the union of ALL sections' diffs: an anchor
377
+ // grounded in a related repo's change is as real as one in the
378
+ // primary's. Sections with no diff (a related repo checked out on its
379
+ // default branch) are collected so an unmatched anchor reads as
380
+ // uncheckable rather than ungrounded.
381
+ const fullState = await stateManager.readFullState();
382
+ const diffSections = [
383
+ { repo: fullState?.metadata?.repository ?? "the primary repository", diff: fullState?.diffText },
384
+ ...Object.entries(fullState?.relatedRepos ?? {}).map(([repo, section]) => ({
385
+ repo,
386
+ diff: section.data?.diffText,
387
+ })),
388
+ ];
389
+ const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
390
+ const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
320
391
  const allScenarios = stateData.repositoryAnalysis?.scenarios ?? [];
321
- const { candidates: agentCandidates, demotions } = buildAgentCandidates(params.candidates ?? [], diffText);
392
+ const agentCandidates = buildAgentCandidates(params.candidates ?? []);
322
393
  const serverCandidates = recoverServerCandidates(stateData.approvedPlan, allScenarios);
323
394
  // Merge by scenario-name identity, NOT the full content-hashed candidateId:
324
395
  // the server (analyze_changes) and the agent frequently draft their own
@@ -341,7 +412,18 @@ export function registerRegisterTestPlanTool(server) {
341
412
  merged.set(mergeKey(candidate), candidate);
342
413
  for (const candidate of serverCandidates)
343
414
  merged.set(mergeKey(candidate), candidate);
344
- const allCandidates = [...merged.values()];
415
+ // The agent declares a claim per scenario NAME, so key the claims the
416
+ // same way the merge does and verify against whichever copy survived.
417
+ const claimsByKey = new Map();
418
+ // Keyed through mergeKey, not scenarioMergeKey alone: a name that
419
+ // slugifies to nothing (e.g. an all-non-ASCII name) would be stored
420
+ // under "" while the lookup falls back to the candidateId, so the claim
421
+ // would be neither verified nor demoted.
422
+ (params.candidates ?? []).forEach((input, i) => {
423
+ if (input.discriminator)
424
+ claimsByKey.set(mergeKey(agentCandidates[i]), input.discriminator);
425
+ });
426
+ const { candidates: allCandidates, demotions } = applyDiscriminatorClaims([...merged.values()], claimsByKey, diffText, reposWithoutDiff);
345
427
  // An empty union would persist an authoritative plan with an empty
346
428
  // GENERATE list, which the generation gate then enforces — bricking
347
429
  // the run until re-registration. Reject instead so the agent can
@@ -351,7 +433,7 @@ export function registerRegisterTestPlanTool(server) {
351
433
  "Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
352
434
  return errorResult;
353
435
  }
354
- const budgetContext = resolveBudgetContext(stateData);
436
+ const budgetContext = resolveBudgetContext(stateData, fullState);
355
437
  const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
356
438
  const approvedPlan = {
357
439
  planId: crypto.randomUUID(),
@@ -26,6 +26,20 @@ export interface TestExecutionResult extends TestExecutionData {
26
26
  testFile: string;
27
27
  videoPath?: string;
28
28
  }
29
+ /**
30
+ * One browser execution's recorded video, as persisted in run state by
31
+ * skyramp_execute_test and read back by skyramp_submit_report (SKYR-4156).
32
+ *
33
+ * `status`/`executedAt` are not rendered in the report — they identify WHICH
34
+ * execution the referenced recording belongs to, which matters because a retried
35
+ * test leaves a video directory behind for every attempt.
36
+ */
37
+ export interface VideoRecord {
38
+ /** Host path to the recorded video.webm. */
39
+ videoPath: string;
40
+ status: TestExecutionStatus;
41
+ executedAt: string;
42
+ }
29
43
  export interface BatchExecutionResult {
30
44
  totalTests: number;
31
45
  passed: number;
@@ -216,8 +216,9 @@ export const baseTestSchema = {
216
216
  .string()
217
217
  .default("")
218
218
  .describe("MUST be string of comma separated values like 'id=1,name=John' for URL query parameters. "
219
- + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically "
220
- + "no need to repeat them here. An explicit value for the same key here overrides the workspace default."),
219
+ + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically, with any "
220
+ + "api.queryParamOverrides entry whose pathPattern matches this endpoint layered on top "
221
+ + "no need to repeat them here. An explicit value for the same key here overrides both."),
221
222
  formParams: z
222
223
  .string()
223
224
  .default("")
@@ -16,7 +16,6 @@ export interface RelatedRepository {
16
16
  export interface TestbotPromptOptions {
17
17
  prTitle: string;
18
18
  prDescription: string;
19
- summaryOutputFile: string;
20
19
  repositoryPath: string;
21
20
  baseBranch?: string;
22
21
  maxRecommendations?: number;
@@ -2,6 +2,15 @@ import type { DriftAction } from "./TestAnalysis.js";
2
2
  import type { TestExecutionStatus } from "./TestExecution.js";
3
3
  import type { HttpMethod, TestType } from "./TestTypes.js";
4
4
  import type { ReuseOutcome } from "./ReuseOutcome.js";
5
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
6
+ * other values are tooling noise that the Testbot report renders in its
7
+ * Configuration Errors section instead of Issues Found. */
8
+ export declare enum IssueFoundCategory {
9
+ Bug = "bug",
10
+ Lint = "lint",
11
+ Type = "type",
12
+ Config = "config"
13
+ }
5
14
  /**
6
15
  * Shape of the JSON report written by skyramp_submit_report and read by testbot
7
16
  * for rendering as Markdown. All fields mirror the corresponding Zod schemas in
@@ -69,6 +78,7 @@ export interface TestbotReport {
69
78
  issuesFound: {
70
79
  description: string;
71
80
  severity?: "critical" | "high" | "medium" | "low";
81
+ category: IssueFoundCategory;
72
82
  }[];
73
83
  nextSteps: string[];
74
84
  commitMessage: string;
@@ -1 +1,10 @@
1
- export {};
1
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
2
+ * other values are tooling noise that the Testbot report renders in its
3
+ * Configuration Errors section instead of Issues Found. */
4
+ export var IssueFoundCategory;
5
+ (function (IssueFoundCategory) {
6
+ IssueFoundCategory["Bug"] = "bug";
7
+ IssueFoundCategory["Lint"] = "lint";
8
+ IssueFoundCategory["Type"] = "type";
9
+ IssueFoundCategory["Config"] = "config";
10
+ })(IssueFoundCategory || (IssueFoundCategory = {}));
@@ -2,6 +2,7 @@ export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
4
  export type { TestbotReport } from "./TestbotReport.js";
5
+ export { IssueFoundCategory } from "./TestbotReport.js";
5
6
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
6
7
  export type { ReuseOutcome, ReuseSkippedEntry } from "./ReuseOutcome.js";
7
8
  export type { RelatedRepository, TestbotPromptOptions, } from "./TestbotPromptOptions.js";
@@ -1,4 +1,5 @@
1
1
  export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
+ export { IssueFoundCategory } from "./TestbotReport.js";
4
5
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
@@ -7,13 +7,33 @@ import type { CandidateUiPage } from "./uiPageEnumerator.js";
7
7
  import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
8
8
  import type { ApprovedPlanItem } from "../types/Recommendation.js";
9
9
  import type { ExternalTestRunRecord } from "../types/ExternalTestExecution.js";
10
+ import type { VideoRecord } from "../types/TestExecution.js";
10
11
  export type { CandidateUiPage } from "./uiPageEnumerator.js";
11
12
  export declare function setTestsRepoDir(dir: string | undefined): void;
12
13
  export declare function getTestsRepoDir(): string | undefined;
13
- /** Filename of the run-scoped analysis state file under `<RUNNER_TEMP>/skyramp/`.
14
+ /** Filename of the run-scoped analysis state file under `runArtifactDir()`.
14
15
  * Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
15
16
  * which LOOKS there cannot drift apart. */
16
17
  export declare const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
18
+ /**
19
+ * Directory holding this run's Skyramp artifacts — the state file, and the report written
20
+ * beside it (SKYR-4147) — or undefined outside a run.
21
+ *
22
+ * `RUNNER_TEMP` is a fresh directory per CI job, and the caller and this server can each
23
+ * work it out from the environment on their own. So neither has to tell the other where
24
+ * the run's files live, and the path never passes through the model.
25
+ *
26
+ * The GitHub Action is the only caller customers run, and GitHub sets `RUNNER_TEMP` for
27
+ * it; the eval harness sets it itself. Nothing customers run is on another CI system, so
28
+ * the GitHub-specific name is only a naming problem — it does not break anything today.
29
+ * If a caller on another CI system is added, rename this then, or let it read that
30
+ * system's variable. Do not add a second variable for a caller that does not exist.
31
+ *
32
+ * Kept in one place for the same reason `RUN_STATE_FILE_NAME` is: the directory used to be
33
+ * written out at three call sites, so only the filename was safe from the three copies
34
+ * drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
35
+ */
36
+ export declare function runArtifactDir(): string | undefined;
17
37
  export declare function setActiveRunStatePath(stateFilePath: string): void;
18
38
  export declare function getActiveRunStatePath(): string | undefined;
19
39
  /** Reset the run anchor — for tests, and as a safety hook between runs. */
@@ -25,7 +45,7 @@ export declare function clearActiveRunStatePath(): void;
25
45
  * `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
26
46
  * runs (the ones carrying a `repository` param), but a single-repo Testbot run's
27
47
  * path is still deterministic in CI: the StateManager constructor above roots
28
- * "analysis" state under `$RUNNER_TEMP/skyramp/`. Falling back to that covers
48
+ * "analysis" state under `runArtifactDir()`. Falling back to that covers
29
49
  * single-repo runs without a new anchor mechanism. Standalone/IDE usage has no
30
50
  * RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
31
51
  *
@@ -166,6 +186,20 @@ export interface UnifiedAnalysisState {
166
186
  * which computes every value in-process; skyramp_submit_report merges it into
167
187
  * the report's `reuse` field. Never supplied by the LLM. */
168
188
  reuseOutcomes?: Record<string, ReuseRecord>;
189
+ /**
190
+ * SKYR-4156. Recorded video per executed browser test, keyed by test-file
191
+ * BASENAME (the same key `reuseOutcomes` uses, so matching needs no path
192
+ * normalization). Written in-process by skyramp_execute_test — the only place
193
+ * the path is known — and merged into the report's `testResults[].videoPath` by
194
+ * skyramp_submit_report. Never supplied by the LLM.
195
+ *
196
+ * Deliberately separate from `existingTests[].executionBefore/After`: those only
197
+ * exist for tests skyramp_analyze_changes discovered, so a NEWLY generated spec
198
+ * has nowhere to record into — which is exactly how one run recorded three videos
199
+ * and referenced none of them. Last write wins, so a retried execution replaces
200
+ * the earlier attempt's now-stale directory.
201
+ */
202
+ executionVideos?: Record<string, VideoRecord>;
169
203
  }
170
204
  /**
171
205
  * State file metadata
@@ -35,10 +35,32 @@ export function getTestsRepoDir() {
35
35
  * Single-repo / standalone calls (no `repository`) never set or read this.
36
36
  */
37
37
  let _activeRunStatePath;
38
- /** Filename of the run-scoped analysis state file under `<RUNNER_TEMP>/skyramp/`.
38
+ /** Filename of the run-scoped analysis state file under `runArtifactDir()`.
39
39
  * Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
40
40
  * which LOOKS there cannot drift apart. */
41
41
  export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
42
+ /**
43
+ * Directory holding this run's Skyramp artifacts — the state file, and the report written
44
+ * beside it (SKYR-4147) — or undefined outside a run.
45
+ *
46
+ * `RUNNER_TEMP` is a fresh directory per CI job, and the caller and this server can each
47
+ * work it out from the environment on their own. So neither has to tell the other where
48
+ * the run's files live, and the path never passes through the model.
49
+ *
50
+ * The GitHub Action is the only caller customers run, and GitHub sets `RUNNER_TEMP` for
51
+ * it; the eval harness sets it itself. Nothing customers run is on another CI system, so
52
+ * the GitHub-specific name is only a naming problem — it does not break anything today.
53
+ * If a caller on another CI system is added, rename this then, or let it read that
54
+ * system's variable. Do not add a second variable for a caller that does not exist.
55
+ *
56
+ * Kept in one place for the same reason `RUN_STATE_FILE_NAME` is: the directory used to be
57
+ * written out at three call sites, so only the filename was safe from the three copies
58
+ * drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
59
+ */
60
+ export function runArtifactDir() {
61
+ const runnerTemp = process.env.RUNNER_TEMP?.trim();
62
+ return runnerTemp ? path.join(runnerTemp, "skyramp") : undefined;
63
+ }
42
64
  export function setActiveRunStatePath(stateFilePath) {
43
65
  _activeRunStatePath = stateFilePath;
44
66
  }
@@ -56,7 +78,7 @@ export function clearActiveRunStatePath() {
56
78
  * `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
57
79
  * runs (the ones carrying a `repository` param), but a single-repo Testbot run's
58
80
  * path is still deterministic in CI: the StateManager constructor above roots
59
- * "analysis" state under `$RUNNER_TEMP/skyramp/`. Falling back to that covers
81
+ * "analysis" state under `runArtifactDir()`. Falling back to that covers
60
82
  * single-repo runs without a new anchor mechanism. Standalone/IDE usage has no
61
83
  * RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
62
84
  *
@@ -70,8 +92,9 @@ export function resolveRunStatePath(explicitPath) {
70
92
  const anchored = getActiveRunStatePath();
71
93
  if (anchored)
72
94
  return anchored;
73
- if (process.env.RUNNER_TEMP) {
74
- return path.join(process.env.RUNNER_TEMP, "skyramp", RUN_STATE_FILE_NAME);
95
+ const runDir = runArtifactDir();
96
+ if (runDir) {
97
+ return path.join(runDir, RUN_STATE_FILE_NAME);
75
98
  }
76
99
  return undefined;
77
100
  }
@@ -169,17 +192,15 @@ export class StateManager {
169
192
  constructor(stateType = "analysis", sessionId, stateDir, stateFilePath) {
170
193
  this.stateType = stateType;
171
194
  this.sessionId = sessionId || crypto.randomUUID();
195
+ const runDir = stateType === "analysis" ? runArtifactDir() : undefined;
172
196
  if (stateFilePath) {
173
197
  this.stateFile = stateFilePath;
174
198
  }
175
- else if (stateType === "analysis" && process.env.RUNNER_TEMP) {
176
- // In CI (testbot action), RUNNER_TEMP is a job-isolated dir that both the
177
- // action and this tool can independently derive — no LLM input needed.
178
- // TODO: interim step remove this branch when stateFile and summaryOutputFile are merged
179
- // (see getTestbotPrompt TODO). RUNNER_TEMP is GitHub Actions-specific; other CI systems
180
- // (Jenkins, GitLab, Buildkite) must set it explicitly. Once merged, path flows through
181
- // summaryOutputFile which is already testbot-controlled and CI-agnostic.
182
- this.stateFile = path.join(process.env.RUNNER_TEMP, "skyramp", RUN_STATE_FILE_NAME);
199
+ else if (runDir) {
200
+ // In CI, the run directory is fresh per job and the caller and this tool each work
201
+ // it out from the environment — no LLM input needed. It is also where
202
+ // skyramp_submit_report writes the report (SKYR-4147).
203
+ this.stateFile = path.join(runDir, RUN_STATE_FILE_NAME);
183
204
  }
184
205
  else {
185
206
  const baseDir = stateDir || os.tmpdir();
@@ -435,7 +456,7 @@ export class StateManager {
435
456
  * @returns Number of files deleted
436
457
  */
437
458
  static async cleanupOldFiles(maxAgeHours = 24, stateDir, stateTypes) {
438
- const baseDir = stateDir || (process.env.RUNNER_TEMP ? path.join(process.env.RUNNER_TEMP, "skyramp") : os.tmpdir());
459
+ const baseDir = stateDir || runArtifactDir() || os.tmpdir();
439
460
  const files = await fs.promises.readdir(baseDir).catch(() => []);
440
461
  const statePrefixes = stateTypes
441
462
  ? stateTypes.map((t) => STATE_FILE_PREFIXES[t])
@@ -905,7 +905,13 @@ export function draftResponseShapeScenarios(hopEndpoints, byFile, addedFieldsByF
905
905
  : `reflects the change made in ${fileName}`;
906
906
  const nameSuffix = addedFields.length > 0 ? addedFields[0] : "response-change";
907
907
  scenarios.push({
908
- scenarioName: `${slugify(resource)}-response-shape-${slugify(nameSuffix)}`,
908
+ // The endpoint path is part of the name because one file can reach
909
+ // several GETs and neither the resource token (from the file) nor the
910
+ // suffix (a field name, or the constant "response-change" when the hunk
911
+ // yields none) varies between them. Two same-named candidates are
912
+ // merged by name in registerTestPlanTool, so a collision here loses a
913
+ // scenario outright rather than showing up as a duplicate.
914
+ scenarioName: `${slugify(resource)}-response-shape-${slugify(nameSuffix)}-${slugify(ep.path)}`,
909
915
  description: `Verify GET ${ep.path} response ${fieldsPhrase}.`,
910
916
  category: "breaking_change",
911
917
  priority: "high",
@@ -2,4 +2,4 @@
2
2
  * Skill content for skyramp.md — installed at auto-discovery paths: ~/.claude/skills/skyramp/SKILLS.md, ~/.cursor/skills/skyramp/SKILLS.md, ~/.github/skills/skyramp.md
3
3
  * Follows the SKILL.md specification: https://agentskills.io/what-are-skills#the-skill-md-file
4
4
  */
5
- export declare const SKYRAMP_MD_CONTENT = "---\nname: skyramp\ndescription: Generate, execute, and maintain API tests (smoke, contract, fuzz, load, integration, E2E, UI) using Skyramp MCP tools.\n---\n\n# Skyramp\n\n## When to use this skill\nUse this skill whenever the user asks to generate, run, or maintain API tests. Always read `<workspace-root>/.skyramp/workspace.yml` first to get `language`, `framework`, `outputDir`, and `api.baseUrl` \u2014 do not ask the user for values already present.\n\n---\n\n## Tools\n\n### Workspace Setup\n1. **`skyramp_initialize_workspace`** \u2014 Create or update `.skyramp/workspace.yml` in a git repository workspace. Scan the repo for all services before calling. Required before any other Skyramp tool.\n\n### Test Generation\n2. **`skyramp_smoke_test_generation`** \u2014 Verify an endpoint is reachable and returns a valid response.\n3. **`skyramp_contract_test_generation`** \u2014 Validate implementation matches OpenAPI/Swagger schema.\n4. **`skyramp_fuzz_test_generation`** \u2014 Send malformed or boundary inputs to find edge cases and security issues.\n5. **`skyramp_load_test_generation`** \u2014 Test performance under concurrent load. Optional: `loadDuration`, `loadNumThreads`. Accepts `trace` instead of `apiSchema`/`endpointURL`.\n6. **`skyramp_integration_test_generation`** \u2014 Multi-step workflows across one or more services. Supply one of: `apiSchema`+`endpointURL`, `trace`, or `scenarioFile`. Do not combine them.\n7. **`skyramp_e2e_test_generation`** \u2014 Full user journey covering UI and backend. Requires `trace` and `playwrightInput` zip. Do not pass `apiSchema` or `endpointURL`.\n8. **`skyramp_ui_test_generation`** \u2014 UI-only tests from Playwright recordings. Requires `playwrightInput` zip.\n\n### Trace Generation\n9. **`skyramp_start_trace_collection`** \u2014 Start capturing backend traffic. Set `playwright: true` for UI or E2E tests. Use an absolute path for `outputDir`.\n10. **`skyramp_stop_trace_collection`** \u2014 Stop capture and save the trace. Use the same `outputDir` and `playwrightEnabled` values as start.\n11. **`skyramp_scenario_test_generation`** \u2014 Scenario trace generation. Describe a multi-step flow in natural language to produce a scenario file. Pass output to `skyramp_integration_test_generation` via `scenarioFile`.\n\n### Test Execution\n12. **`skyramp_execute_test`** \u2014 Run a single Skyramp-generated test file. Required: `workspacePath`, `language`, `testType`, `testFile`. Optional: `stateFile` (writes execution results back for health analysis). For multiple tests, call sequentially to avoid env var conflicts.\n\n### Test Analysis & Maintenance\n13. **`skyramp_analyze_changes`** \u2014 Unified entry point: scans endpoints, discovers tests, computes diff. Takes `repositoryPath` and `scope`. Returns `stateFile` + recommendations.\n14. **`skyramp_analyze_test_health`** \u2014 Drift and health assessment for existing tests. Takes `stateFile`. Returns LLM prompt for scoring.\n15. **`skyramp_actions`** \u2014 Execute UPDATE / REGENERATE / VERIFY / DELETE actions. Takes `stateFile`. Call after analyze_test_health.\n\n### Code Quality\n16. **`skyramp_fix_errors`** \u2014 Fix compilation or runtime errors in a generated test file.\n17. **`skyramp_modularization`** \u2014 Refactor a test file into reusable modules. Set `isTraceBased: true` for trace-based tests.\n18. **`skyramp_reuse_code`** \u2014 Pull shared helpers from other Skyramp tests. Only when `code_reuse` was `true` at generation time.\n\n### Authentication\n19. **`skyramp_login`** \u2014 Log in to the Skyramp platform.\n20. **`skyramp_logout`** \u2014 Log out from the Skyramp platform.\n\n---\n\n## Prompts\n\nPrefer invoking a prompt over manually chaining tools \u2014 prompts run the full workflow automatically.\n\n- **`skyramp_trace_prompt`** \u2014 Trace collection setup and execution.\n- **`skyramp_test_health_analysis`** \u2014 Full maintenance flow (discover \u2192 drift \u2192 health \u2192 actions).\n- **`skyramp_testbot`** \u2014 PR-scoped recommendations + maintenance + report. Required: `prTitle`, `prDescription`, `summaryOutputFile`, `repositoryPath`.\n\n---\n\n## Workflows\n\nUse these when a prompt is not available.\n\n**Generate and run a test**\nRead `.skyramp/workspace.yml` \u2192 Call appropriate generation tool \u2192 `skyramp_execute_test`\n\n**Trace-based test (integration / load / E2E / UI)**\n`skyramp_start_trace_collection` (set `playwright: true` for UI/E2E) \u2192 User exercises the app \u2192 `skyramp_stop_trace_collection` \u2192 Call target generation tool with `trace` (and `playwrightInput` for E2E/UI)\n\n**Scenario \u2192 integration test**\n`skyramp_scenario_test_generation` \u2192 `skyramp_integration_test_generation` with `scenarioFile`\n\n**Recommend tests for a PR**\n`skyramp_analyze_changes` (with `scope: \"branch_diff\"`) \u2192 follow enrichment steps \u2192 `skyramp_recommend_tests` \u2192 Generate recommended tests\n\n**Maintain existing tests**\n`skyramp_analyze_changes` \u2192 `skyramp_analyze_test_health` \u2192 *(optional)* execute tests via `skyramp_execute_test` with `stateFile` param (writes results back) \u2192 `skyramp_actions` (do not skip)\n\n---\n\n## Conventions\n\n- **`endpointURL`** \u2014 Full URL to a specific endpoint, not just the base URL. Build from `api.baseUrl` + path (e.g. `http://localhost:8000/api/v1/users`).\n- **`outputDir`** \u2014 Use absolute paths for both test output and trace collection.\n- **Mutually exclusive inputs** \u2014 `apiSchema`/`endpointURL`, `trace`, and `scenarioFile` are mutually exclusive for integration and load tests. Use exactly one.\n- **Sequential execution** \u2014 Execute tests sequentially (not in parallel) to avoid environment variable conflicts with `SKYRAMP_TEST_BASE_URL`.\n";
5
+ export declare const SKYRAMP_MD_CONTENT = "---\nname: skyramp\ndescription: Generate, execute, and maintain API tests (smoke, contract, fuzz, load, integration, E2E, UI) using Skyramp MCP tools.\n---\n\n# Skyramp\n\n## When to use this skill\nUse this skill whenever the user asks to generate, run, or maintain API tests. Always read `<workspace-root>/.skyramp/workspace.yml` first to get `language`, `framework`, `outputDir`, and `api.baseUrl` \u2014 do not ask the user for values already present.\n\n---\n\n## Tools\n\n### Workspace Setup\n1. **`skyramp_initialize_workspace`** \u2014 Create or update `.skyramp/workspace.yml` in a git repository workspace. Scan the repo for all services before calling. Required before any other Skyramp tool.\n\n### Test Generation\n2. **`skyramp_smoke_test_generation`** \u2014 Verify an endpoint is reachable and returns a valid response.\n3. **`skyramp_contract_test_generation`** \u2014 Validate implementation matches OpenAPI/Swagger schema.\n4. **`skyramp_fuzz_test_generation`** \u2014 Send malformed or boundary inputs to find edge cases and security issues.\n5. **`skyramp_load_test_generation`** \u2014 Test performance under concurrent load. Optional: `loadDuration`, `loadNumThreads`. Accepts `trace` instead of `apiSchema`/`endpointURL`.\n6. **`skyramp_integration_test_generation`** \u2014 Multi-step workflows across one or more services. Supply one of: `apiSchema`+`endpointURL`, `trace`, or `scenarioFile`. Do not combine them.\n7. **`skyramp_e2e_test_generation`** \u2014 Full user journey covering UI and backend. Requires `trace` and `playwrightInput` zip. Do not pass `apiSchema` or `endpointURL`.\n8. **`skyramp_ui_test_generation`** \u2014 UI-only tests from Playwright recordings. Requires `playwrightInput` zip.\n\n### Trace Generation\n9. **`skyramp_start_trace_collection`** \u2014 Start capturing backend traffic. Set `playwright: true` for UI or E2E tests. Use an absolute path for `outputDir`.\n10. **`skyramp_stop_trace_collection`** \u2014 Stop capture and save the trace. Use the same `outputDir` and `playwrightEnabled` values as start.\n11. **`skyramp_scenario_test_generation`** \u2014 Scenario trace generation. Describe a multi-step flow in natural language to produce a scenario file. Pass output to `skyramp_integration_test_generation` via `scenarioFile`.\n\n### Test Execution\n12. **`skyramp_execute_test`** \u2014 Run a single Skyramp-generated test file. Required: `workspacePath`, `language`, `testType`, `testFile`. Optional: `stateFile` (writes execution results back for health analysis). For multiple tests, call sequentially to avoid env var conflicts.\n\n### Test Analysis & Maintenance\n13. **`skyramp_analyze_changes`** \u2014 Unified entry point: scans endpoints, discovers tests, computes diff. Takes `repositoryPath` and `scope`. Returns `stateFile` + recommendations.\n14. **`skyramp_analyze_test_health`** \u2014 Drift and health assessment for existing tests. Takes `stateFile`. Returns LLM prompt for scoring.\n15. **`skyramp_actions`** \u2014 Execute UPDATE / REGENERATE / VERIFY / DELETE actions. Takes `stateFile`. Call after analyze_test_health.\n\n### Code Quality\n16. **`skyramp_fix_errors`** \u2014 Fix compilation or runtime errors in a generated test file.\n17. **`skyramp_modularization`** \u2014 Refactor a test file into reusable modules. Set `isTraceBased: true` for trace-based tests.\n18. **`skyramp_reuse_code`** \u2014 Pull shared helpers from other Skyramp tests. Only when `code_reuse` was `true` at generation time.\n\n### Authentication\n19. **`skyramp_login`** \u2014 Log in to the Skyramp platform.\n20. **`skyramp_logout`** \u2014 Log out from the Skyramp platform.\n\n---\n\n## Prompts\n\nPrefer invoking a prompt over manually chaining tools \u2014 prompts run the full workflow automatically.\n\n- **`skyramp_trace_prompt`** \u2014 Trace collection setup and execution.\n- **`skyramp_test_health_analysis`** \u2014 Full maintenance flow (discover \u2192 drift \u2192 health \u2192 actions).\n- **`skyramp_testbot`** \u2014 PR-scoped recommendations + maintenance + report. Required: `prTitle`, `prDescription`, `repositoryPath`.\n\n---\n\n## Workflows\n\nUse these when a prompt is not available.\n\n**Generate and run a test**\nRead `.skyramp/workspace.yml` \u2192 Call appropriate generation tool \u2192 `skyramp_execute_test`\n\n**Trace-based test (integration / load / E2E / UI)**\n`skyramp_start_trace_collection` (set `playwright: true` for UI/E2E) \u2192 User exercises the app \u2192 `skyramp_stop_trace_collection` \u2192 Call target generation tool with `trace` (and `playwrightInput` for E2E/UI)\n\n**Scenario \u2192 integration test**\n`skyramp_scenario_test_generation` \u2192 `skyramp_integration_test_generation` with `scenarioFile`\n\n**Recommend tests for a PR**\n`skyramp_analyze_changes` (with `scope: \"branch_diff\"`) \u2192 follow enrichment steps \u2192 `skyramp_recommend_tests` \u2192 Generate recommended tests\n\n**Maintain existing tests**\n`skyramp_analyze_changes` \u2192 `skyramp_analyze_test_health` \u2192 *(optional)* execute tests via `skyramp_execute_test` with `stateFile` param (writes results back) \u2192 `skyramp_actions` (do not skip)\n\n---\n\n## Conventions\n\n- **`endpointURL`** \u2014 Full URL to a specific endpoint, not just the base URL. Build from `api.baseUrl` + path (e.g. `http://localhost:8000/api/v1/users`).\n- **`outputDir`** \u2014 Use absolute paths for both test output and trace collection.\n- **Mutually exclusive inputs** \u2014 `apiSchema`/`endpointURL`, `trace`, and `scenarioFile` are mutually exclusive for integration and load tests. Use exactly one.\n- **Sequential execution** \u2014 Execute tests sequentially (not in parallel) to avoid environment variable conflicts with `SKYRAMP_TEST_BASE_URL`.\n";
@@ -58,7 +58,7 @@ Prefer invoking a prompt over manually chaining tools — prompts run the full w
58
58
 
59
59
  - **\`skyramp_trace_prompt\`** — Trace collection setup and execution.
60
60
  - **\`skyramp_test_health_analysis\`** — Full maintenance flow (discover → drift → health → actions).
61
- - **\`skyramp_testbot\`** — PR-scoped recommendations + maintenance + report. Required: \`prTitle\`, \`prDescription\`, \`summaryOutputFile\`, \`repositoryPath\`.
61
+ - **\`skyramp_testbot\`** — PR-scoped recommendations + maintenance + report. Required: \`prTitle\`, \`prDescription\`, \`repositoryPath\`.
62
62
 
63
63
  ---
64
64
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * URL/path derivations shared by the generation tools.
3
+ *
4
+ * Both helpers here existed as private copies before: `pathFromEndpointURL`
5
+ * lived in tools/generate-tests/planGuard.ts, and the base-path derivation was
6
+ * inlined in ScenarioGenerationService and then mirrored a second time by
7
+ * generateBatchScenarioRestTool so it could predict the path the service would
8
+ * emit (SKYR-4127). Two copies of "which path does this request actually have"
9
+ * is exactly the kind of thing that drifts silently — a pattern that stops
10
+ * matching produces a 403, not a test failure — so they live in one place now.
11
+ *
12
+ * Dependency-free on purpose: this sits below both services/ and tools/ so
13
+ * either layer can import it without an inversion.
14
+ */
15
+ /**
16
+ * Extract the URL path from a generation tool's `endpointURL` param.
17
+ *
18
+ * Falls back to treating the value as a bare path when it isn't a parseable
19
+ * absolute URL, and to undefined when it's neither.
20
+ *
21
+ * `new URL()` percent-encodes `{param}` placeholders (`{id}` → `%7Bid%7D`),
22
+ * which breaks two consumers: plan matchKeys are computed from raw scenario
23
+ * paths with braces intact, and workspace queryParamOverrides patterns are
24
+ * authored with braces. Restore them.
25
+ */
26
+ export declare function pathFromEndpointURL(endpointURL: string | undefined): string | undefined;
27
+ /**
28
+ * The path prefix a service's `baseURL` contributes to every request under it:
29
+ * the URL's pathname with any trailing slash removed. Empty string when
30
+ * `baseURL` is absent or unparseable, and for a host-only URL (`http://host`
31
+ * → pathname `/` → `""`).
32
+ *
33
+ * This is the value ScenarioGenerationService prepends to each step path, so
34
+ * anything that needs to reason about the emitted path — such as matching
35
+ * workspace queryParamOverrides — must derive it the same way.
36
+ */
37
+ export declare function deriveBasePath(baseURL: string | undefined): string;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * URL/path derivations shared by the generation tools.
3
+ *
4
+ * Both helpers here existed as private copies before: `pathFromEndpointURL`
5
+ * lived in tools/generate-tests/planGuard.ts, and the base-path derivation was
6
+ * inlined in ScenarioGenerationService and then mirrored a second time by
7
+ * generateBatchScenarioRestTool so it could predict the path the service would
8
+ * emit (SKYR-4127). Two copies of "which path does this request actually have"
9
+ * is exactly the kind of thing that drifts silently — a pattern that stops
10
+ * matching produces a 403, not a test failure — so they live in one place now.
11
+ *
12
+ * Dependency-free on purpose: this sits below both services/ and tools/ so
13
+ * either layer can import it without an inversion.
14
+ */
15
+ /**
16
+ * Extract the URL path from a generation tool's `endpointURL` param.
17
+ *
18
+ * Falls back to treating the value as a bare path when it isn't a parseable
19
+ * absolute URL, and to undefined when it's neither.
20
+ *
21
+ * `new URL()` percent-encodes `{param}` placeholders (`{id}` → `%7Bid%7D`),
22
+ * which breaks two consumers: plan matchKeys are computed from raw scenario
23
+ * paths with braces intact, and workspace queryParamOverrides patterns are
24
+ * authored with braces. Restore them.
25
+ */
26
+ export function pathFromEndpointURL(endpointURL) {
27
+ if (!endpointURL)
28
+ return undefined;
29
+ try {
30
+ return new URL(endpointURL).pathname.replace(/%7B/gi, "{").replace(/%7D/gi, "}");
31
+ }
32
+ catch {
33
+ return endpointURL.startsWith("/") ? endpointURL : undefined;
34
+ }
35
+ }
36
+ /**
37
+ * The path prefix a service's `baseURL` contributes to every request under it:
38
+ * the URL's pathname with any trailing slash removed. Empty string when
39
+ * `baseURL` is absent or unparseable, and for a host-only URL (`http://host`
40
+ * → pathname `/` → `""`).
41
+ *
42
+ * This is the value ScenarioGenerationService prepends to each step path, so
43
+ * anything that needs to reason about the emitted path — such as matching
44
+ * workspace queryParamOverrides — must derive it the same way.
45
+ */
46
+ export function deriveBasePath(baseURL) {
47
+ if (!baseURL)
48
+ return "";
49
+ try {
50
+ return new URL(baseURL).pathname.replace(/\/$/, "");
51
+ }
52
+ catch {
53
+ return "";
54
+ }
55
+ }