@skyramp/mcp 0.1.8 → 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/index.js +4 -2
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +4 -3
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +180 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -5,9 +5,16 @@ import path from "path";
|
|
|
5
5
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
6
6
|
import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
|
|
7
7
|
import { TestSource } from "../../types/TestAnalysis.js";
|
|
8
|
+
import { toolError } from "../../utils/utils.js";
|
|
8
9
|
const TOOL_NAME = "skyramp_analyze_test_health";
|
|
9
10
|
export function registerAnalyzeTestHealthTool(server) {
|
|
10
11
|
server.registerTool(TOOL_NAME, {
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: false, // deletes old state files via StateManager.cleanupOldFiles
|
|
14
|
+
destructiveHint: false,
|
|
15
|
+
idempotentHint: true,
|
|
16
|
+
openWorldHint: false,
|
|
17
|
+
},
|
|
11
18
|
description: `Generate drift and health assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
|
|
12
19
|
|
|
13
20
|
**PREREQUISITE:** Call \`skyramp_analyze_changes\` first to get a stateFile.
|
|
@@ -15,19 +22,16 @@ export function registerAnalyzeTestHealthTool(server) {
|
|
|
15
22
|
This tool reads existing tests, the branch diff, and scanned endpoints from the stateFile,
|
|
16
23
|
then returns a structured prompt for the LLM to assess each test for drift and health.
|
|
17
24
|
|
|
18
|
-
The LLM follows the returned prompt to assign
|
|
19
|
-
actions (UPDATE / REGENERATE / VERIFY / DELETE) for each test, then calls \`skyramp_actions\`.
|
|
20
|
-
|
|
21
|
-
**This tool does not write drift or health data to the stateFile.** Assessment results
|
|
22
|
-
exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\`.
|
|
25
|
+
The LLM follows the returned prompt to assign drift details and actions (UPDATE / REGENERATE / VERIFY / DELETE / IGNORE) for each test, then calls \`skyramp_actions\`.
|
|
23
26
|
|
|
24
|
-
(Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live
|
|
25
|
-
|
|
26
|
-
**Output:** LLM drift analysis prompt and assessment instructions.`,
|
|
27
|
+
(Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live.`,
|
|
27
28
|
inputSchema: {
|
|
28
29
|
stateFile: z
|
|
29
30
|
.string()
|
|
30
|
-
.describe("Path to state file from skyramp_analyze_changes"),
|
|
31
|
+
.describe("Path to state file from skyramp_analyze_changes. Assessment results exist only in the LLM's reasoning context — this tool does not write back to the stateFile."),
|
|
32
|
+
},
|
|
33
|
+
outputSchema: {
|
|
34
|
+
prompt: z.string().describe("LLM drift analysis prompt and assessment instructions."),
|
|
31
35
|
},
|
|
32
36
|
}, async (args) => {
|
|
33
37
|
let errorResult;
|
|
@@ -39,19 +43,7 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
|
|
|
39
43
|
const fullState = await stateManager.readFullState();
|
|
40
44
|
const repositoryPath = fullState?.metadata.repositoryPath || "";
|
|
41
45
|
if (!stateData) {
|
|
42
|
-
|
|
43
|
-
content: [
|
|
44
|
-
{
|
|
45
|
-
type: "text",
|
|
46
|
-
text: JSON.stringify({
|
|
47
|
-
error: "State file is empty or invalid",
|
|
48
|
-
stateFile: args.stateFile,
|
|
49
|
-
}, null, 2),
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
isError: true,
|
|
53
|
-
};
|
|
54
|
-
return errorResult;
|
|
46
|
+
return toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
|
|
55
47
|
}
|
|
56
48
|
// Only Skyramp tests are candidates for drift analysis and maintenance actions.
|
|
57
49
|
// External (user-written) tests are used only for recommendation deduplication.
|
|
@@ -59,83 +51,40 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
|
|
|
59
51
|
const existingTests = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
|
|
60
52
|
logger.info(`Loaded ${existingTests.length} existing Skyramp tests from state file (excluded external)`);
|
|
61
53
|
if (!repositoryPath || typeof repositoryPath !== "string") {
|
|
62
|
-
|
|
63
|
-
content: [
|
|
64
|
-
{
|
|
65
|
-
type: "text",
|
|
66
|
-
text: JSON.stringify({
|
|
67
|
-
error: "repositoryPath not found in state file metadata",
|
|
68
|
-
details: "State file must contain repositoryPath in metadata",
|
|
69
|
-
}, null, 2),
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
isError: true,
|
|
73
|
-
};
|
|
74
|
-
return errorResult;
|
|
54
|
+
return toolError(`repositoryPath not found in state file metadata. The state file was likely created by an older version — re-run skyramp_analyze_changes to regenerate it.`);
|
|
75
55
|
}
|
|
76
56
|
const absoluteRepoPath = path.resolve(repositoryPath);
|
|
77
|
-
// ── Build and return drift analysis prompt ──
|
|
78
|
-
const diff = stateData.repositoryAnalysis?.diff;
|
|
79
|
-
// Build structured diff summary for the prompt
|
|
80
|
-
let parsedDiffText = "";
|
|
81
|
-
if (diff) {
|
|
82
|
-
const lines = [];
|
|
83
|
-
if (diff.currentBranch)
|
|
84
|
-
lines.push(`**Branch**: ${diff.currentBranch} → base: ${diff.baseBranch}`);
|
|
85
|
-
if (diff.changedFiles?.length > 0)
|
|
86
|
-
lines.push(`**Changed Files** (${diff.changedFiles.length}): ${diff.changedFiles.join(", ")}`);
|
|
87
|
-
if (diff.newEndpoints?.length > 0) {
|
|
88
|
-
lines.push(`**New Endpoints** (${diff.newEndpoints.length}): ${diff.newEndpoints
|
|
89
|
-
.map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
|
|
90
|
-
.join(", ")}`);
|
|
91
|
-
}
|
|
92
|
-
if (diff.modifiedEndpoints?.length > 0) {
|
|
93
|
-
lines.push(`**Modified Endpoints** (${diff.modifiedEndpoints.length}): ${diff.modifiedEndpoints
|
|
94
|
-
.map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
|
|
95
|
-
.join(", ")}`);
|
|
96
|
-
}
|
|
97
|
-
if (diff.removedEndpoints?.length > 0) {
|
|
98
|
-
lines.push(`**Removed Endpoints** (${diff.removedEndpoints.length}): ${diff.removedEndpoints
|
|
99
|
-
.map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
|
|
100
|
-
.join(", ")}`);
|
|
101
|
-
}
|
|
102
|
-
parsedDiffText = lines.join("\n");
|
|
103
|
-
}
|
|
104
57
|
const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
|
|
105
58
|
const routerMountContext = stateData.repositoryAnalysis?.routerMountContext;
|
|
106
59
|
const candidateRouteFiles = stateData.repositoryAnalysis?.candidateRouteFiles;
|
|
60
|
+
const diffFilePath = stateData.repositoryAnalysis?.diffFilePath;
|
|
61
|
+
// Sweep stale diff files on this natural follow-up call so they don't accumulate.
|
|
62
|
+
// Pass [] for stateTypes so only skyramp-diff-*.diff files are deleted — state files
|
|
63
|
+
// (skyramp-analysis-*, skyramp-recommendation-*) must not be removed here because the
|
|
64
|
+
// caller still needs args.stateFile to pass to skyramp_actions.
|
|
65
|
+
try {
|
|
66
|
+
await StateManager.cleanupOldFiles(24, undefined, []);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.warning(`Failed to cleanup old diff files: ${error.message}`);
|
|
70
|
+
}
|
|
107
71
|
const promptText = buildDriftAnalysisPrompt({
|
|
108
72
|
existingTests,
|
|
109
|
-
parsedDiff: parsedDiffText || undefined,
|
|
110
73
|
scannedEndpoints,
|
|
111
74
|
repositoryPath: absoluteRepoPath,
|
|
112
75
|
stateFile: stateManager.getStatePath(),
|
|
113
76
|
routerMountContext,
|
|
114
77
|
candidateRouteFiles,
|
|
78
|
+
diffFilePath,
|
|
115
79
|
});
|
|
116
80
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
type: "text",
|
|
120
|
-
text: promptText,
|
|
121
|
-
},
|
|
122
|
-
],
|
|
81
|
+
structuredContent: { prompt: promptText },
|
|
82
|
+
content: [{ type: "text", text: "Drift analysis prompt generated. Follow the prompt field to assess each test." }],
|
|
123
83
|
};
|
|
124
84
|
}
|
|
125
85
|
catch (error) {
|
|
126
86
|
logger.error(`Test health analysis failed: ${error.message}`, error);
|
|
127
|
-
errorResult = {
|
|
128
|
-
content: [
|
|
129
|
-
{
|
|
130
|
-
type: "text",
|
|
131
|
-
text: JSON.stringify({
|
|
132
|
-
error: error.message,
|
|
133
|
-
details: error.stack,
|
|
134
|
-
}, null, 2),
|
|
135
|
-
},
|
|
136
|
-
],
|
|
137
|
-
isError: true,
|
|
138
|
-
};
|
|
87
|
+
errorResult = toolError(`Test health analysis failed: ${error.message}`);
|
|
139
88
|
return errorResult;
|
|
140
89
|
}
|
|
141
90
|
finally {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { registerAnalyzeChangesTool } from "./analyzeChangesTool.js";
|
|
2
|
+
export { registerUiAnalyzeChangesTool } from "./uiAnalyzeChangesTool.js";
|
|
2
3
|
export { registerAnalyzeTestHealthTool } from "./analyzeTestHealthTool.js";
|
|
3
4
|
export { registerActionsTool } from "./actionsTool.js";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { logger } from "../../utils/logger.js";
|
|
5
|
+
import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
|
|
6
|
+
import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
7
|
+
import { parseChangedFilesFromDiff } from "../../utils/branchDiff.js";
|
|
8
|
+
import { toolText } from "../../utils/utils.js";
|
|
9
|
+
import { isTestbotEnabled } from "../../utils/featureFlags.js";
|
|
10
|
+
/**
|
|
11
|
+
* skyramp_ui_analyze_changes — runs before skyramp_analyze_changes when the
|
|
12
|
+
* caller (typically the testbot agent) wants blueprint-grounded recommendations.
|
|
13
|
+
*
|
|
14
|
+
* Computes candidateUiPages[] using the same source-grounded logic that
|
|
15
|
+
* analyze_changes uses internally (shared enumerator), then emits explicit
|
|
16
|
+
* instructions for the agent: navigate to each candidate URL, capture a
|
|
17
|
+
* browser_blueprint, then call skyramp_analyze_changes with the captures
|
|
18
|
+
* available as element vocabulary when the agent writes UI rec reasoning.
|
|
19
|
+
*
|
|
20
|
+
* Graceful degradation: backend-only PRs produce a "no UI work" instruction;
|
|
21
|
+
* unresolvable frontend baseUrl produces "skip captures, call analyze_changes
|
|
22
|
+
* with empty array." analyze_changes still works in either case — same
|
|
23
|
+
* source-grounded output as before slice 4.
|
|
24
|
+
*/
|
|
25
|
+
export async function runUiAnalyzeChanges(params) {
|
|
26
|
+
const repoPath = params.repositoryPath;
|
|
27
|
+
// Read changedFiles from the canonical PR diff file. The testbot runtime
|
|
28
|
+
// writes this before the prompt runs.
|
|
29
|
+
const diffPath = path.join(repoPath, ".skyramp_git_diff");
|
|
30
|
+
let changedFiles = [];
|
|
31
|
+
let diffFileMissing = false;
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(diffPath, "utf-8");
|
|
34
|
+
changedFiles = parseChangedFilesFromDiff(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// No diff file — for testbot this would be a runtime bug (it always
|
|
38
|
+
// writes one); for non-testbot callers it means "diff unavailable",
|
|
39
|
+
// which is NOT the same as "no UI changes". Surface that distinction.
|
|
40
|
+
diffFileMissing = true;
|
|
41
|
+
}
|
|
42
|
+
if (diffFileMissing) {
|
|
43
|
+
const uiContext = {
|
|
44
|
+
changedFrontendFiles: [],
|
|
45
|
+
candidateUiPages: [],
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
uiContext,
|
|
49
|
+
instructions: DIFF_FILE_MISSING_INSTRUCTIONS,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
|
|
53
|
+
if (frontendFiles.length === 0) {
|
|
54
|
+
const uiContext = {
|
|
55
|
+
changedFrontendFiles: [],
|
|
56
|
+
candidateUiPages: [],
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
uiContext,
|
|
60
|
+
instructions: NO_UI_INSTRUCTIONS,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const candidateUiPages = await enumerateCandidateUiPages(repoPath, frontendFiles);
|
|
64
|
+
const uiContext = {
|
|
65
|
+
changedFrontendFiles: frontendFiles,
|
|
66
|
+
candidateUiPages,
|
|
67
|
+
};
|
|
68
|
+
const instructions = candidateUiPages.length === 0
|
|
69
|
+
? NO_RESOLVABLE_URLS_INSTRUCTIONS
|
|
70
|
+
: buildCaptureInstructions(candidateUiPages, params.uiCredentials);
|
|
71
|
+
logger.info("ui_analyze_changes computed candidate UI pages", {
|
|
72
|
+
repositoryPath: repoPath,
|
|
73
|
+
frontendFileCount: frontendFiles.length,
|
|
74
|
+
candidateUiPageCount: candidateUiPages.length,
|
|
75
|
+
hasUiCredentials: !!params.uiCredentials,
|
|
76
|
+
});
|
|
77
|
+
return { uiContext, instructions };
|
|
78
|
+
}
|
|
79
|
+
const NO_UI_INSTRUCTIONS = `No UI changes detected in this PR. Proceed directly to \`skyramp_analyze_changes\`.`;
|
|
80
|
+
const NO_RESOLVABLE_URLS_INSTRUCTIONS = `Frontend changes detected but no candidate URLs could be resolved (workspace baseUrl missing or no router files matched). Proceed to \`skyramp_analyze_changes\`; UI recommendations will be source-grounded only.`;
|
|
81
|
+
const DIFF_FILE_MISSING_INSTRUCTIONS = `\`.skyramp_git_diff\` not found in the repository. This file is normally written by the testbot runtime before the prompt runs; without it, this tool cannot determine which files changed. **Do not skip blueprint capture on the assumption there are no UI changes** — that conclusion is not supported by the available data. If you have access to the changed-files list elsewhere (e.g. a \`gh pr diff\` command, an environment variable), determine UI candidates yourself and capture their blueprints before proceeding. Otherwise proceed to \`skyramp_analyze_changes\` and add an \`issuesFound\` info entry: "ui_analyze_changes could not read .skyramp_git_diff; recommendations are source-grounded only".`;
|
|
82
|
+
function buildCaptureInstructions(pages, uiCredentials) {
|
|
83
|
+
// The `<ui-credentials>` tag is only emitted by the testbot orchestration
|
|
84
|
+
// prompt. When this tool is called from an IDE (Cursor, etc.) outside
|
|
85
|
+
// testbot, the agent has no such tag — embed the credentials inline
|
|
86
|
+
// instead. uiCredentials is "email:password" formatted by the caller.
|
|
87
|
+
const loginStep = uiCredentials
|
|
88
|
+
? (isTestbotEnabled()
|
|
89
|
+
? `\n0. **Log in first.** \`browser_navigate\` to the app's login URL, then use \`<ui-credentials>\` from the testbot prompt to authenticate (you have those credentials in context). Without authentication, the candidate URLs below may redirect to login and the captured blueprints will be of the login page, not the feature.\n`
|
|
90
|
+
: `\n0. **Log in first.** \`browser_navigate\` to the app's login URL, then authenticate with the credentials \`${uiCredentials}\` (formatted as \`email:password\`). Without authentication, the candidate URLs below may redirect to login and the captured blueprints will be of the login page, not the feature.\n`)
|
|
91
|
+
: "";
|
|
92
|
+
const pagesYaml = pages
|
|
93
|
+
.map((p, i) => ` ${i + 1}. ${p.url} (sourcedFrom: ${p.sourcedFrom.join(", ") || "(none)"}, strategy: ${p.strategy})`)
|
|
94
|
+
.join("\n");
|
|
95
|
+
return `Frontend changes detected. **Before calling \`skyramp_analyze_changes\`, capture blueprints on the candidate UI pages below.** Those captures stay in your tool-result history and serve as element vocabulary when you write UI test recommendations — \`skyramp_analyze_changes\` returns the authoring rules; you bring the observed elements.
|
|
96
|
+
${loginStep}
|
|
97
|
+
**Candidate URLs:**
|
|
98
|
+
${pagesYaml}
|
|
99
|
+
|
|
100
|
+
**For each candidate URL:**
|
|
101
|
+
- \`browser_navigate\` to the URL
|
|
102
|
+
- \`browser_blueprint\` to capture the page
|
|
103
|
+
|
|
104
|
+
You don't need to thread the blueprints back into a tool call — they're in your context once captured. **Then call \`skyramp_analyze_changes\`** to get the recommendation catalog and the UI authoring rules.
|
|
105
|
+
|
|
106
|
+
If a candidate URL 404s or redirects unexpectedly, navigate from the workspace baseUrl and explore (admin apps mount routes under base prefixes the source extraction can't see). If the page rendered but lacks the changed feature (gated UI: modal, dropdown, accordion), do NOT iterate further during this step — UI recs will fall back to source-grounded prose for those, and the agent's later trace recording (Task 2) will navigate into the gate via capture-act-capture.
|
|
107
|
+
|
|
108
|
+
If \`browser_blueprint\` fails on every candidate URL (app unreachable, all 404s), proceed to \`skyramp_analyze_changes\` and log an \`issuesFound\` info entry. Recommendations will be source-grounded; non-UI work is unaffected.`;
|
|
109
|
+
}
|
|
110
|
+
export const uiAnalyzeChangesInputSchema = {
|
|
111
|
+
repositoryPath: z
|
|
112
|
+
.string()
|
|
113
|
+
.describe("Absolute path to the repository root"),
|
|
114
|
+
uiCredentials: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Optional ui-credentials for authed apps. When present, the returned " +
|
|
118
|
+
"instructions include a login step before blueprint capture."),
|
|
119
|
+
};
|
|
120
|
+
export function registerUiAnalyzeChangesTool(server) {
|
|
121
|
+
server.registerTool("skyramp_ui_analyze_changes", {
|
|
122
|
+
description: [
|
|
123
|
+
"Run BEFORE skyramp_analyze_changes when the PR may contain frontend changes.",
|
|
124
|
+
"Enumerates candidate UI pages from the diff and instructs the agent to",
|
|
125
|
+
"capture browser_blueprints on each before calling analyze_changes. The",
|
|
126
|
+
"captures stay in the agent's tool-result history and serve as the",
|
|
127
|
+
"element vocabulary for UI rec reasoning.",
|
|
128
|
+
"",
|
|
129
|
+
"On backend-only PRs, returns a 'no UI work' instruction — caller can",
|
|
130
|
+
"proceed directly to skyramp_analyze_changes.",
|
|
131
|
+
].join("\n"),
|
|
132
|
+
inputSchema: uiAnalyzeChangesInputSchema,
|
|
133
|
+
annotations: {
|
|
134
|
+
readOnlyHint: true, // reads .skyramp_git_diff + workspace.yml; no writes
|
|
135
|
+
idempotentHint: true, // pure function of repo state + uiCredentials
|
|
136
|
+
openWorldHint: false, // no external service calls
|
|
137
|
+
},
|
|
138
|
+
}, async (params) => {
|
|
139
|
+
const result = await runUiAnalyzeChanges(params);
|
|
140
|
+
const summary = JSON.stringify({ uiContext: result.uiContext }, null, 2);
|
|
141
|
+
return toolText(`<summary>
|
|
142
|
+
${summary}
|
|
143
|
+
</summary>
|
|
144
|
+
|
|
145
|
+
<instructions>
|
|
146
|
+
${result.instructions}
|
|
147
|
+
</instructions>`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
jest.mock("@skyramp/skyramp", () => ({
|
|
2
|
+
WorkspaceConfigManager: { create: jest.fn() },
|
|
3
|
+
}));
|
|
4
|
+
import { runUiAnalyzeChanges } from "./uiAnalyzeChangesTool.js";
|
|
5
|
+
import * as workspaceAuth from "../../utils/workspaceAuth.js";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
function makeRepoWithDiff(changedFiles) {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-uiac-test-"));
|
|
11
|
+
// Write .skyramp_git_diff with the requested file changes — match the
|
|
12
|
+
// format the runtime uses (raw `diff --git a/X b/X` block).
|
|
13
|
+
const diff = changedFiles
|
|
14
|
+
.map((f) => `diff --git a/${f} b/${f}\n+++ b/${f}\n@@ -0,0 +1,1 @@\n+x`)
|
|
15
|
+
.join("\n");
|
|
16
|
+
fs.writeFileSync(path.join(dir, ".skyramp_git_diff"), diff);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
describe("runUiAnalyzeChanges", () => {
|
|
20
|
+
let readSpy;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
readSpy = jest.spyOn(workspaceAuth, "readWorkspaceConfigRaw");
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
readSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
it("returns empty changedFrontendFiles for backend-only diffs", async () => {
|
|
28
|
+
readSpy.mockResolvedValue({ services: [] });
|
|
29
|
+
const repo = makeRepoWithDiff(["api/handlers/users.ts"]);
|
|
30
|
+
const result = await runUiAnalyzeChanges({ repositoryPath: repo });
|
|
31
|
+
expect(result.uiContext.changedFrontendFiles).toEqual([]);
|
|
32
|
+
expect(result.uiContext.candidateUiPages).toEqual([]);
|
|
33
|
+
expect(result.instructions).toMatch(/no UI changes/i);
|
|
34
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
it("returns candidateUiPages and capture instructions for frontend diffs", async () => {
|
|
37
|
+
readSpy.mockResolvedValue({
|
|
38
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
39
|
+
});
|
|
40
|
+
const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
|
|
41
|
+
const result = await runUiAnalyzeChanges({ repositoryPath: repo });
|
|
42
|
+
expect(result.uiContext.changedFrontendFiles.length).toBeGreaterThan(0);
|
|
43
|
+
expect(result.uiContext.candidateUiPages.length).toBeGreaterThan(0);
|
|
44
|
+
expect(result.instructions).toMatch(/browser_navigate/);
|
|
45
|
+
expect(result.instructions).toMatch(/browser_blueprint/);
|
|
46
|
+
expect(result.instructions).toMatch(/skyramp_analyze_changes/);
|
|
47
|
+
// Captures stay in agent's tool-result history; we no longer thread them
|
|
48
|
+
// back through analyze_changes as a param.
|
|
49
|
+
expect(result.instructions).not.toMatch(/capturedBlueprints/);
|
|
50
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
it("includes inline-credentials login step in non-testbot mode", async () => {
|
|
53
|
+
// SKYRAMP_FEATURE_TESTBOT is not set here, so the tool emits the
|
|
54
|
+
// credentials inline rather than referencing the testbot's
|
|
55
|
+
// <ui-credentials> context tag (which doesn't exist outside testbot).
|
|
56
|
+
delete process.env.SKYRAMP_FEATURE_TESTBOT;
|
|
57
|
+
readSpy.mockResolvedValue({
|
|
58
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
59
|
+
});
|
|
60
|
+
const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
|
|
61
|
+
const result = await runUiAnalyzeChanges({
|
|
62
|
+
repositoryPath: repo,
|
|
63
|
+
uiCredentials: "admin@example.com:admin",
|
|
64
|
+
});
|
|
65
|
+
expect(result.instructions).toMatch(/log in/i);
|
|
66
|
+
expect(result.instructions).toContain("admin@example.com:admin");
|
|
67
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
it("references the <ui-credentials> testbot context tag in testbot mode", async () => {
|
|
70
|
+
process.env.SKYRAMP_FEATURE_TESTBOT = "1";
|
|
71
|
+
try {
|
|
72
|
+
readSpy.mockResolvedValue({
|
|
73
|
+
services: [{ serviceName: "frontend", api: { baseUrl: "http://localhost:3000" } }],
|
|
74
|
+
});
|
|
75
|
+
const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
|
|
76
|
+
const result = await runUiAnalyzeChanges({
|
|
77
|
+
repositoryPath: repo,
|
|
78
|
+
uiCredentials: "admin@example.com:admin",
|
|
79
|
+
});
|
|
80
|
+
expect(result.instructions).toMatch(/log in/i);
|
|
81
|
+
expect(result.instructions).toContain("ui-credentials");
|
|
82
|
+
// Inline credentials should NOT leak in testbot mode (the
|
|
83
|
+
// <ui-credentials> tag is the secure carrier in that flow).
|
|
84
|
+
expect(result.instructions).not.toContain("admin@example.com:admin");
|
|
85
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
delete process.env.SKYRAMP_FEATURE_TESTBOT;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it("instructs the agent to proceed to analyze_changes (source-grounded) when frontend baseUrl unresolvable", async () => {
|
|
92
|
+
readSpy.mockResolvedValue(null); // no workspace yml
|
|
93
|
+
const repo = makeRepoWithDiff(["frontend/src/components/Cart.tsx"]);
|
|
94
|
+
const result = await runUiAnalyzeChanges({ repositoryPath: repo });
|
|
95
|
+
expect(result.uiContext.changedFrontendFiles.length).toBeGreaterThan(0);
|
|
96
|
+
expect(result.uiContext.candidateUiPages).toEqual([]);
|
|
97
|
+
expect(result.instructions).toMatch(/source-grounded/i);
|
|
98
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the user-supplied `playwrightSaveStoragePath` against `outputDir`:
|
|
4
|
+
* - A bare filename (no separators, not absolute) is joined with `outputDir`.
|
|
5
|
+
* - Anything else (absolute path, or a relative path containing separators)
|
|
6
|
+
* is returned unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveSaveStoragePath(input, outputDir) {
|
|
9
|
+
if (!outputDir) {
|
|
10
|
+
return input;
|
|
11
|
+
}
|
|
12
|
+
if (!path.isAbsolute(input) && !input.includes(path.sep)) {
|
|
13
|
+
return path.join(outputDir, input);
|
|
14
|
+
}
|
|
15
|
+
return input;
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { resolveSaveStoragePath } from "./resolveSaveStoragePath.js";
|
|
3
|
+
describe("resolveSaveStoragePath", () => {
|
|
4
|
+
const outputDir = "/abs/output";
|
|
5
|
+
it("joins a bare filename with outputDir", () => {
|
|
6
|
+
expect(resolveSaveStoragePath("auth.json", outputDir)).toBe(path.join(outputDir, "auth.json"));
|
|
7
|
+
});
|
|
8
|
+
it("returns an absolute path unchanged", () => {
|
|
9
|
+
expect(resolveSaveStoragePath("/tmp/storage.json", outputDir)).toBe("/tmp/storage.json");
|
|
10
|
+
});
|
|
11
|
+
it("returns a relative path with separators unchanged", () => {
|
|
12
|
+
expect(resolveSaveStoragePath("subdir/storage.json", outputDir)).toBe("subdir/storage.json");
|
|
13
|
+
});
|
|
14
|
+
it("returns input unchanged when outputDir is undefined", () => {
|
|
15
|
+
expect(resolveSaveStoragePath("auth.json", undefined)).toBe("auth.json");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { SESSION_STORAGE_FILENAME } from "../../types/TestTypes.js";
|
|
3
|
+
import { resolveSaveStoragePath } from "./resolveSaveStoragePath.js";
|
|
4
|
+
export function resolveSessionPaths(args) {
|
|
5
|
+
const defaultPath = path.join(args.outputDir, SESSION_STORAGE_FILENAME);
|
|
6
|
+
const explicitLoad = args.loadOverride
|
|
7
|
+
? resolveSaveStoragePath(args.loadOverride, args.outputDir)
|
|
8
|
+
: undefined;
|
|
9
|
+
const explicitSave = args.saveOverride
|
|
10
|
+
? resolveSaveStoragePath(args.saveOverride, args.outputDir)
|
|
11
|
+
: undefined;
|
|
12
|
+
switch (args.mode) {
|
|
13
|
+
case "capture":
|
|
14
|
+
// Login capture: clean slate (never load), save at default unless overridden.
|
|
15
|
+
return { savePath: explicitSave ?? defaultPath };
|
|
16
|
+
case "reuse":
|
|
17
|
+
// Reuse existing session: load by default, do NOT save (preserves the file).
|
|
18
|
+
// Caller may force a save by setting `playwrightSaveStoragePath` explicitly.
|
|
19
|
+
return {
|
|
20
|
+
loadPath: explicitLoad ?? defaultPath,
|
|
21
|
+
savePath: explicitSave,
|
|
22
|
+
};
|
|
23
|
+
case "ignore":
|
|
24
|
+
// Honor only explicit overrides; otherwise neither load nor save.
|
|
25
|
+
return { loadPath: explicitLoad, savePath: explicitSave };
|
|
26
|
+
case "auto":
|
|
27
|
+
default:
|
|
28
|
+
// Explicit overrides win.
|
|
29
|
+
if (explicitLoad || explicitSave) {
|
|
30
|
+
return { loadPath: explicitLoad, savePath: explicitSave };
|
|
31
|
+
}
|
|
32
|
+
// No overrides — decide from filesystem state. If a session file is
|
|
33
|
+
// already present, reuse it without overwriting; otherwise capture
|
|
34
|
+
// into the default location.
|
|
35
|
+
return args.sessionExists
|
|
36
|
+
? { loadPath: defaultPath }
|
|
37
|
+
: { savePath: defaultPath };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { SESSION_STORAGE_FILENAME } from "../../types/TestTypes.js";
|
|
3
|
+
import { resolveSessionPaths } from "./resolveSessionPaths.js";
|
|
4
|
+
describe("resolveSessionPaths", () => {
|
|
5
|
+
const outputDir = "/abs/output";
|
|
6
|
+
const defaultPath = path.join(outputDir, SESSION_STORAGE_FILENAME);
|
|
7
|
+
const baseArgs = {
|
|
8
|
+
loadOverride: undefined,
|
|
9
|
+
saveOverride: undefined,
|
|
10
|
+
outputDir,
|
|
11
|
+
sessionExists: false,
|
|
12
|
+
};
|
|
13
|
+
describe("mode=auto", () => {
|
|
14
|
+
it("captures into the default path when no session exists", () => {
|
|
15
|
+
expect(resolveSessionPaths({ ...baseArgs, mode: "auto" })).toEqual({
|
|
16
|
+
savePath: defaultPath,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
it("reuses (loads) the default path when a session exists, without saving", () => {
|
|
20
|
+
expect(resolveSessionPaths({ ...baseArgs, mode: "auto", sessionExists: true })).toEqual({ loadPath: defaultPath });
|
|
21
|
+
});
|
|
22
|
+
it("respects an explicit load override when given (auto + sessionExists)", () => {
|
|
23
|
+
expect(resolveSessionPaths({
|
|
24
|
+
...baseArgs,
|
|
25
|
+
mode: "auto",
|
|
26
|
+
sessionExists: true,
|
|
27
|
+
loadOverride: "/elsewhere/session.json",
|
|
28
|
+
})).toEqual({ loadPath: "/elsewhere/session.json" });
|
|
29
|
+
});
|
|
30
|
+
it("respects an explicit save override (auto + no session)", () => {
|
|
31
|
+
expect(resolveSessionPaths({
|
|
32
|
+
...baseArgs,
|
|
33
|
+
mode: "auto",
|
|
34
|
+
saveOverride: "auth.json",
|
|
35
|
+
})).toEqual({ savePath: path.join(outputDir, "auth.json") });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("mode=capture", () => {
|
|
39
|
+
it("always saves at the default path, never loads", () => {
|
|
40
|
+
expect(resolveSessionPaths({ ...baseArgs, mode: "capture" })).toEqual({
|
|
41
|
+
savePath: defaultPath,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it("ignores a load override (capture is a clean slate)", () => {
|
|
45
|
+
expect(resolveSessionPaths({
|
|
46
|
+
...baseArgs,
|
|
47
|
+
mode: "capture",
|
|
48
|
+
loadOverride: "/elsewhere/session.json",
|
|
49
|
+
})).toEqual({ savePath: defaultPath });
|
|
50
|
+
});
|
|
51
|
+
it("honors an explicit save override", () => {
|
|
52
|
+
expect(resolveSessionPaths({
|
|
53
|
+
...baseArgs,
|
|
54
|
+
mode: "capture",
|
|
55
|
+
saveOverride: "/tmp/custom.json",
|
|
56
|
+
})).toEqual({ savePath: "/tmp/custom.json" });
|
|
57
|
+
});
|
|
58
|
+
it("still captures even when a session file already exists (we're recapturing)", () => {
|
|
59
|
+
expect(resolveSessionPaths({
|
|
60
|
+
...baseArgs,
|
|
61
|
+
mode: "capture",
|
|
62
|
+
sessionExists: true,
|
|
63
|
+
})).toEqual({ savePath: defaultPath });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("mode=reuse", () => {
|
|
67
|
+
it("loads the default path, does not save (preserves the session file)", () => {
|
|
68
|
+
expect(resolveSessionPaths({ ...baseArgs, mode: "reuse" })).toEqual({
|
|
69
|
+
loadPath: defaultPath,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
it("honors an explicit load override", () => {
|
|
73
|
+
expect(resolveSessionPaths({
|
|
74
|
+
...baseArgs,
|
|
75
|
+
mode: "reuse",
|
|
76
|
+
loadOverride: "/elsewhere/session.json",
|
|
77
|
+
})).toEqual({ loadPath: "/elsewhere/session.json" });
|
|
78
|
+
});
|
|
79
|
+
it("allows an explicit save override even in reuse (caller wants to refresh)", () => {
|
|
80
|
+
expect(resolveSessionPaths({
|
|
81
|
+
...baseArgs,
|
|
82
|
+
mode: "reuse",
|
|
83
|
+
saveOverride: "/tmp/refreshed.json",
|
|
84
|
+
})).toEqual({ loadPath: defaultPath, savePath: "/tmp/refreshed.json" });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe("mode=ignore", () => {
|
|
88
|
+
it("loads and saves nothing by default", () => {
|
|
89
|
+
expect(resolveSessionPaths({ ...baseArgs, mode: "ignore" })).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
it("honors explicit overrides only", () => {
|
|
92
|
+
expect(resolveSessionPaths({
|
|
93
|
+
...baseArgs,
|
|
94
|
+
mode: "ignore",
|
|
95
|
+
loadOverride: "/in/session.json",
|
|
96
|
+
saveOverride: "/out/session.json",
|
|
97
|
+
})).toEqual({
|
|
98
|
+
loadPath: "/in/session.json",
|
|
99
|
+
savePath: "/out/session.json",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Module-level state acting as a singleton: the start-trace tool stashes the
|
|
2
|
+
// resolved save path here so the stop-trace tool can surface it without
|
|
3
|
+
// re-resolving. ES modules are cached and singleton per Node process, so this
|
|
4
|
+
// is the idiomatic TS pattern for cross-call state — equivalent to a class
|
|
5
|
+
// with private static fields, without the extra wrapper.
|
|
6
|
+
let savedPath;
|
|
7
|
+
export function setSavedSessionPath(p) {
|
|
8
|
+
savedPath = p;
|
|
9
|
+
}
|
|
10
|
+
export function consumeSavedSessionPath() {
|
|
11
|
+
const v = savedPath;
|
|
12
|
+
savedPath = undefined;
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { consumeSavedSessionPath, setSavedSessionPath, } from "./sessionState.js";
|
|
2
|
+
describe("sessionState", () => {
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
// Ensure module-level state is cleared between tests.
|
|
5
|
+
setSavedSessionPath(undefined);
|
|
6
|
+
});
|
|
7
|
+
it("returns the set value on first consume and undefined on the second", () => {
|
|
8
|
+
setSavedSessionPath("/abs/session.json");
|
|
9
|
+
expect(consumeSavedSessionPath()).toBe("/abs/session.json");
|
|
10
|
+
expect(consumeSavedSessionPath()).toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
it("setting undefined clears any prior value", () => {
|
|
13
|
+
setSavedSessionPath("/abs/session.json");
|
|
14
|
+
setSavedSessionPath(undefined);
|
|
15
|
+
expect(consumeSavedSessionPath()).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
});
|