@skyramp/mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,13 +40,23 @@ export async function registerPlaywrightTools(server, options) {
40
40
  'browser_select_option',
41
41
  'browser_hover',
42
42
  'browser_drag',
43
+ 'browser_mouse_move_xy',
44
+ 'browser_mouse_click_xy',
45
+ 'browser_mouse_drag_xy',
46
+ 'browser_mouse_down',
47
+ 'browser_mouse_up',
48
+ 'browser_file_upload',
49
+ 'browser_evaluate',
43
50
  'browser_tabs',
44
51
  'browser_navigate_back',
45
52
  'browser_wait_for',
46
53
  'browser_take_screenshot',
47
54
  'browser_assert',
48
55
  'browser_assert_api_request',
56
+ 'browser_assert_table_cell',
49
57
  'skyramp_export_zip',
58
+ 'skyramp_load_trace',
59
+ 'browser_mouse_action',
50
60
  // DOM Analyzer tools (Phase C)
51
61
  'browser_blueprint',
52
62
  'browser_blueprint_diff',
@@ -1,24 +1,125 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
1
3
  import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "./recommendationSections.js";
2
4
  // .vue and .svelte cannot be route handlers in any framework — always frontend.
3
5
  const ALWAYS_FRONTEND_EXT = /\.(vue|svelte)$/i;
6
+ // .dart is always frontend in Flutter projects (no server-side Dart web framework
7
+ // in scope). Gated on hasFlutterPubspec so non-Flutter Dart files (rare, but
8
+ // possible — e.g. Dart-on-server backends) don't accidentally classify as UI.
9
+ const FLUTTER_DART_EXT = /\.dart$/i;
4
10
  // .tsx/.jsx are usually UI components but can be Next.js API handlers (pages/api/*.tsx).
5
11
  const LIKELY_FRONTEND_EXT = /\.(tsx|jsx)$/i;
6
12
  // Ambiguous extensions need directory context to distinguish frontend from backend.
7
13
  const AMBIGUOUS_FRONTEND_PATTERN = /\.(tsx?|jsx?|css|scss|less|html?|erb|jsp|asp|jinja2?|twig)$/i;
8
14
  const API_DIR_PATTERN = /\/(api|routes?|controllers?|routers?|handlers?|endpoints?|server)\//;
9
15
  const FRONTEND_DIR_PATTERN = /(^|\/)(components?|pages?|views?|layouts?|app|src\/app|frontend|client|public|styles?|templates?)\//i;
16
+ /**
17
+ * Returns true if the repository root's `pubspec.yaml` declares a Flutter
18
+ * SDK dependency (i.e., a `flutter:` key with `sdk: flutter` under
19
+ * `dependencies:`). This is the load-bearing signal that the project
20
+ * is a Flutter UI app or package, NOT a pure Dart server / CLI / library
21
+ * (those have a `pubspec.yaml` too but no `sdk: flutter` dep).
22
+ *
23
+ * Used as the gate for treating `.dart` files as frontend in
24
+ * `isFrontendFile`. Pure-Dart-backend repos correctly fall through to
25
+ * the existing classifier behaviour (no `.dart` recognition).
26
+ *
27
+ * Synchronous + best-effort: returns false on any read/parse error.
28
+ * Cheap because pubspec.yaml is small and we only call this once per
29
+ * tool invocation (computed at the budget-driving boundary, then passed
30
+ * through as an option).
31
+ */
32
+ /**
33
+ * Common subdir locations for `pubspec.yaml` in real Flutter repos:
34
+ * - root (single-app — birdle, customer SPA)
35
+ * - app/, mobile/, frontend/, web/ (mixed-stack repos)
36
+ * - apps/<name>/, packages/<name>/ (monorepos — depth-2 walked)
37
+ *
38
+ * We stop at the first pubspec we find that declares `sdk: flutter`. Pure-Dart
39
+ * subprojects (e.g. a CLI tool in `tools/`) are correctly skipped because their
40
+ * pubspec doesn't have the Flutter SDK line.
41
+ *
42
+ * Less-common shapes that are NOT auto-discovered: `services/mobile/`,
43
+ * `clients/<name>/`, etc. If a customer's repo uses one of those, add the
44
+ * parent dir here or tell them to symlink `pubspec.yaml` at the root.
45
+ */
46
+ const PUBSPEC_SEARCH_DIRS = [
47
+ ".",
48
+ "app",
49
+ "mobile",
50
+ "frontend",
51
+ "web",
52
+ "client",
53
+ ];
54
+ const PUBSPEC_MONOREPO_PARENTS = ["apps", "packages"];
55
+ export function hasFlutterSdkDep(repositoryPath) {
56
+ for (const subdir of PUBSPEC_SEARCH_DIRS) {
57
+ if (checkPubspec(path.join(repositoryPath, subdir, "pubspec.yaml"))) {
58
+ return true;
59
+ }
60
+ }
61
+ // Monorepo shape: apps/<name>/pubspec.yaml, packages/<name>/pubspec.yaml.
62
+ // One level of fan-out — enough for typical Flutter monorepos, cheap to walk.
63
+ for (const parent of PUBSPEC_MONOREPO_PARENTS) {
64
+ const parentPath = path.join(repositoryPath, parent);
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(parentPath, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ for (const entry of entries) {
73
+ if (!entry.isDirectory())
74
+ continue;
75
+ if (checkPubspec(path.join(parentPath, entry.name, "pubspec.yaml"))) {
76
+ return true;
77
+ }
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+ function checkPubspec(pubspecPath) {
83
+ try {
84
+ if (!fs.existsSync(pubspecPath))
85
+ return false;
86
+ const content = fs.readFileSync(pubspecPath, "utf8");
87
+ // Detect the `flutter:` SDK dep — proper YAML parsing would be overkill.
88
+ // The canonical pattern is:
89
+ // dependencies:
90
+ // flutter:
91
+ // sdk: flutter
92
+ // The `sdk: flutter` line under a `flutter:` key is unique to Flutter
93
+ // projects; pure-Dart projects use `sdk: dart` or no sdk: line at all.
94
+ return /^\s+sdk:\s*flutter\s*$/m.test(content);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
10
100
  /**
11
101
  * Returns true if the file path is a frontend file.
12
102
  *
13
- * Three tiers:
103
+ * Four tiers:
14
104
  * 1. .vue / .svelte — always frontend; these file types cannot be route handlers.
15
- * 2. .tsx / .jsx frontend unless in an API/backend directory (e.g. pages/api/).
16
- * 3. Ambiguous extensions (.ts, .js, .css, .html, …) require a recognised
105
+ * 2. .dart always frontend IFF `hasFlutterSdkDep` is true (Flutter project).
106
+ * Sits in tier 1 because there's no server-side Dart web framework in scope
107
+ * when the Flutter SDK dep is present, so `API_DIR_PATTERN` doesn't apply.
108
+ * 3. .tsx / .jsx — frontend unless in an API/backend directory (e.g. pages/api/).
109
+ * 4. Ambiguous extensions (.ts, .js, .css, .html, …) — require a recognised
17
110
  * frontend directory AND must not be in an API/backend directory.
111
+ *
112
+ * The `hasFlutterSdkDep` opt is passed in by the budget-driving callers
113
+ * (uiAnalyzeChangesTool, analyzeChangesTool) which have repository context.
114
+ * Pure-path callers (test-recommendation-prompt, analysisOutputPrompt,
115
+ * scopeAssessment's own buildScopeAssessmentSection) default to false —
116
+ * they don't drive the budget.
18
117
  */
19
- export function isFrontendFile(filePath) {
118
+ export function isFrontendFile(filePath, { hasFlutterSdkDep = false } = {}) {
20
119
  if (ALWAYS_FRONTEND_EXT.test(filePath))
21
120
  return true;
121
+ if (hasFlutterSdkDep && FLUTTER_DART_EXT.test(filePath))
122
+ return true;
22
123
  if (API_DIR_PATTERN.test(filePath))
23
124
  return false;
24
125
  if (LIKELY_FRONTEND_EXT.test(filePath))
@@ -113,7 +214,7 @@ Read the Changed Files list and endpoint changes above, then work through the fo
113
214
 
114
215
  **Step A — Classify changed files:**
115
216
  Count each type from the diff context (ignore generated test files, lock files, and build artifacts):
116
- - **Frontend files**: .vue / .svelte anywhere (always UI components). .tsx / .jsx anywhere except in api/, routes/, routers/, controllers/, handlers/, endpoints/, or server/ directories. .ts / .js / .html / .css / .scss / .less / .erb / .jsp / .asp / .jinja2 / .twig only when in a frontend directory (components/, pages/, views/, layouts/, app/, frontend/, client/, styles/, templates/).
217
+ - **Frontend files**: .vue / .svelte anywhere (always UI components). .dart anywhere (always UI in a Flutter project — repo has a \`pubspec.yaml\` with \`sdk: flutter\`). .tsx / .jsx anywhere except in api/, routes/, routers/, controllers/, handlers/, endpoints/, or server/ directories. .ts / .js / .html / .css / .scss / .less / .erb / .jsp / .asp / .jinja2 / .twig only when in a frontend directory (components/, pages/, views/, layouts/, app/, frontend/, client/, styles/, templates/).
117
218
  - **Backend files**: route handlers, controllers, services, models, API modules, middleware, config with business logic
118
219
  - **Non-application** (exclude from test value): CSS-only, copy/string changes, README, CI config with no logic
119
220
 
@@ -1,7 +1,10 @@
1
1
  jest.mock("@skyramp/skyramp", () => ({
2
2
  WorkspaceConfigManager: { create: jest.fn() },
3
3
  }));
4
- import { isFrontendFile, isTestFile, buildScopeAssessmentSection } from "./scopeAssessment.js";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import { hasFlutterSdkDep, isFrontendFile, isTestFile, buildScopeAssessmentSection } from "./scopeAssessment.js";
5
8
  // ---------------------------------------------------------------------------
6
9
  // isFrontendFile
7
10
  // ---------------------------------------------------------------------------
@@ -56,6 +59,130 @@ describe("isFrontendFile", () => {
56
59
  it("returns false for a plain TS API client in utils/ (no frontend dir)", () => {
57
60
  expect(isFrontendFile("utils/apiClient.ts")).toBe(false);
58
61
  });
62
+ // Flutter / Dart support — gated on hasFlutterSdkDep.
63
+ // Without the flag, .dart is unrecognised (legacy behaviour). With it,
64
+ // .dart is always-frontend (tier 2, before API_DIR_PATTERN).
65
+ describe(".dart files (Flutter)", () => {
66
+ it("returns false for .dart without hasFlutterSdkDep (default)", () => {
67
+ expect(isFrontendFile("lib/main.dart")).toBe(false);
68
+ });
69
+ it("returns false for .dart with hasFlutterSdkDep: false", () => {
70
+ expect(isFrontendFile("lib/main.dart", { hasFlutterSdkDep: false })).toBe(false);
71
+ });
72
+ it("returns true for .dart with hasFlutterSdkDep: true", () => {
73
+ expect(isFrontendFile("lib/main.dart", { hasFlutterSdkDep: true })).toBe(true);
74
+ });
75
+ it("returns true for nested .dart files in a Flutter project", () => {
76
+ expect(isFrontendFile("lib/widgets/game_board.dart", { hasFlutterSdkDep: true })).toBe(true);
77
+ });
78
+ it("returns true for .dart even in a directory that would otherwise look API-like (Flutter has no server-side Dart in scope)", () => {
79
+ // .dart sits in tier 2 (above API_DIR_PATTERN) so it's frontend even
80
+ // under api/ — there's no server-side Dart web framework that would
81
+ // make this ambiguous in the Flutter case.
82
+ expect(isFrontendFile("lib/api/client.dart", { hasFlutterSdkDep: true })).toBe(true);
83
+ });
84
+ it("returns true for game.dart at repo root in a Flutter project", () => {
85
+ expect(isFrontendFile("game.dart", { hasFlutterSdkDep: true })).toBe(true);
86
+ });
87
+ });
88
+ });
89
+ // ---------------------------------------------------------------------------
90
+ // hasFlutterSdkDep
91
+ // ---------------------------------------------------------------------------
92
+ describe("hasFlutterSdkDep", () => {
93
+ let tmpDir;
94
+ beforeEach(() => {
95
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "flutter-pubspec-"));
96
+ });
97
+ afterEach(() => {
98
+ fs.rmSync(tmpDir, { recursive: true, force: true });
99
+ });
100
+ it("returns false when pubspec.yaml is absent", () => {
101
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
102
+ });
103
+ it("returns true when pubspec.yaml has the canonical Flutter SDK dep", () => {
104
+ fs.writeFileSync(path.join(tmpDir, "pubspec.yaml"), `name: birdle
105
+ description: A Flutter app.
106
+ dependencies:
107
+ flutter:
108
+ sdk: flutter
109
+ cupertino_icons: ^1.0.6
110
+ `);
111
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
112
+ });
113
+ it("returns false for a pure Dart project (no flutter SDK dep)", () => {
114
+ // Pure Dart server (e.g. shelf, dart_frog) has pubspec.yaml but no flutter SDK
115
+ fs.writeFileSync(path.join(tmpDir, "pubspec.yaml"), `name: my_dart_server
116
+ description: A Dart server using shelf.
117
+ environment:
118
+ sdk: ">=3.0.0 <4.0.0"
119
+ dependencies:
120
+ shelf: ^1.4.0
121
+ shelf_router: ^1.1.4
122
+ `);
123
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
124
+ });
125
+ it("returns false for a Dart CLI tool (no flutter SDK dep)", () => {
126
+ fs.writeFileSync(path.join(tmpDir, "pubspec.yaml"), `name: my_cli
127
+ description: A Dart CLI.
128
+ environment:
129
+ sdk: ^3.5.0
130
+ dependencies:
131
+ args: ^2.5.0
132
+ `);
133
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
134
+ });
135
+ it("returns false on a malformed pubspec.yaml (graceful failure)", () => {
136
+ fs.writeFileSync(path.join(tmpDir, "pubspec.yaml"), "::: not yaml ::: \x00");
137
+ // Our regex-based check still works on string content; this is more of
138
+ // a "doesn't throw" test. Returns false because the malformed content
139
+ // doesn't match the sdk: flutter pattern.
140
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
141
+ });
142
+ it("returns true even with extra whitespace and comments in pubspec.yaml", () => {
143
+ fs.writeFileSync(path.join(tmpDir, "pubspec.yaml"), `name: birdle
144
+ # This is the SDK dep
145
+ dependencies:
146
+ flutter:
147
+ sdk: flutter
148
+ `);
149
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
150
+ });
151
+ // Subdir search — common shapes in real Flutter repos.
152
+ it("returns true when pubspec.yaml lives in app/", () => {
153
+ fs.mkdirSync(path.join(tmpDir, "app"));
154
+ fs.writeFileSync(path.join(tmpDir, "app", "pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n");
155
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
156
+ });
157
+ it("returns true when pubspec.yaml lives in mobile/", () => {
158
+ fs.mkdirSync(path.join(tmpDir, "mobile"));
159
+ fs.writeFileSync(path.join(tmpDir, "mobile", "pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n");
160
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
161
+ });
162
+ it("returns true for monorepo with apps/<name>/pubspec.yaml", () => {
163
+ fs.mkdirSync(path.join(tmpDir, "apps", "customer-app"), { recursive: true });
164
+ fs.writeFileSync(path.join(tmpDir, "apps", "customer-app", "pubspec.yaml"), "name: customer_app\ndependencies:\n flutter:\n sdk: flutter\n");
165
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
166
+ });
167
+ it("returns true for monorepo with packages/<name>/pubspec.yaml", () => {
168
+ fs.mkdirSync(path.join(tmpDir, "packages", "ui"), { recursive: true });
169
+ fs.writeFileSync(path.join(tmpDir, "packages", "ui", "pubspec.yaml"), "name: ui\ndependencies:\n flutter:\n sdk: flutter\n");
170
+ expect(hasFlutterSdkDep(tmpDir)).toBe(true);
171
+ });
172
+ it("returns false when monorepo subdirs only contain pure-Dart packages", () => {
173
+ // Mixed monorepo: a Dart CLI in packages/cli/, no Flutter app anywhere.
174
+ fs.mkdirSync(path.join(tmpDir, "packages", "cli"), { recursive: true });
175
+ fs.writeFileSync(path.join(tmpDir, "packages", "cli", "pubspec.yaml"), "name: cli\ndependencies:\n args: ^2.5.0\n");
176
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
177
+ });
178
+ it("does not walk arbitrary subdirs (e.g. node_modules-style nested deps)", () => {
179
+ // A pubspec deep under an unsupported subdir should NOT be picked up.
180
+ // Without this guard, a transitive dependency's pubspec could falsely
181
+ // flag a non-Flutter repo as Flutter.
182
+ fs.mkdirSync(path.join(tmpDir, "vendor", "nested", "lib"), { recursive: true });
183
+ fs.writeFileSync(path.join(tmpDir, "vendor", "nested", "lib", "pubspec.yaml"), "name: nested\ndependencies:\n flutter:\n sdk: flutter\n");
184
+ expect(hasFlutterSdkDep(tmpDir)).toBe(false);
185
+ });
59
186
  });
60
187
  // ---------------------------------------------------------------------------
61
188
  // isTestFile
@@ -127,7 +127,7 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
127
127
  Keep advancing until you have created exactly ${maxGenerate} new test files OR exhausted all candidates.
128
128
  - Example: If enrichment reveals that sending \`discount_value\` without \`discount_type\` silently orphans the value (a concrete bug), complete all planned GENERATE items first, then generate this discovered scenario as an extra test and report it in \`newTestsCreated\`.
129
129
  - Total generated: Follow the "Budget: N generate" line in the Execution Plan. Process every GENERATE-tagged item in order. Backfill from ADDITIONAL candidates (highest-ranked first) until \`newTestsCreated\` reaches ${maxGenerate} or all candidates are exhausted.
130
- - **UI test priority**: If the diff contains frontend/UI changes (e.g. \`.tsx\`, \`.jsx\`, \`.vue\`, \`.svelte\` files), you MUST attempt to generate at least one UI test. Use \`browser_navigate\` to the app's base URL — if the app responds, record a trace and generate the test.
130
+ - **UI test priority**: If the PR scope assessment shows any UI/E2E budget OR \`uiContext.changedFrontendFiles\` is non-empty (the deterministic server signal — populated for all supported frontend file types including \`.tsx\`/\`.jsx\`/\`.vue\`/\`.svelte\`/\`.dart\`), you MUST attempt to generate at least one UI test. Use \`browser_navigate\` to the app's base URL — if the app responds, record a trace and generate the test.
131
131
  **Skip only if one of these conditions is met:**
132
132
  - **(a) App is unreachable** — \`browser_navigate\` fails or connection is refused.
133
133
  - **(b) Unintegrated non-route component** — the changed file is a leaf component (not a framework route/entrypoint) that has no integration point in the running app. To confirm:
@@ -273,7 +273,7 @@ If a test **generation** tool call fails:
273
273
  1. **Retry once** with the same parameters.
274
274
  2. If it fails again, **skip** that candidate and move to the next ranked candidate.
275
275
  3. If all candidates in the GENERATE set fail, fall back to generating the **simplest possible test**: a single contract test for the highest-scored endpoint (GET → 200 or POST → 201).
276
- **Exception — frontend-only PRs**: If the diff modifies ONLY frontend files (\`.tsx\`, \`.jsx\`, \`.vue\`, \`.svelte\`, \`.css\`, \`.html\`) AND browser recording was not possible, do NOT generate a backend fallback contract test — it is irrelevant to the PR. Instead move ALL GENERATE candidates to \`additionalRecommendations\` and proceed to Task 3.
276
+ **Exception — frontend-only PRs**: If the diff modifies ONLY frontend files (\`.tsx\`, \`.jsx\`, \`.vue\`, \`.svelte\`, \`.dart\`, \`.css\`, \`.html\`) AND browser recording was not possible, do NOT generate a backend fallback contract test — it is irrelevant to the PR. Instead move ALL GENERATE candidates to \`additionalRecommendations\` and proceed to Task 3.
277
277
  4. Log skipped candidates in \`issuesFound\` with the error message.
278
278
 
279
279
  If a test **execution** (\`skyramp_execute_test\`) fails for a newly generated test:
@@ -360,4 +360,25 @@ describe("testbot prompt blueprint-grounded recommendations (slice 4)", () => {
360
360
  // Make sure we removed the old capturedBlueprints threading directive.
361
361
  expect(prompt).not.toMatch(/capturedBlueprints/);
362
362
  });
363
+ // Flutter support — both the generalised UI trigger wording and the
364
+ // canvas/empty-ARIA issuesFound rule should appear in the prompt.
365
+ it("UI test priority defers to the deterministic server signal (uiContext.changedFrontendFiles), not just hard-coded extensions", () => {
366
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
367
+ // The trigger must reference the server signal (changedFrontendFiles)
368
+ // — that's what makes it framework-agnostic. Hard-coded extension lists
369
+ // become illustrative, not gating.
370
+ expect(prompt).toMatch(/uiContext.*changedFrontendFiles|changedFrontendFiles.*uiContext/);
371
+ // .dart should appear in the supported-types example list
372
+ expect(prompt).toMatch(/\.dart/);
373
+ });
374
+ it("frontend-only PR exception lists .dart alongside other frontend extensions", () => {
375
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
376
+ // The frontend-only-PR exception block (Task 2 backend-fallback skip)
377
+ // must include .dart so a Flutter-only PR doesn't emit a backend
378
+ // contract test as a fallback.
379
+ const exceptionBlock = prompt.slice(prompt.indexOf("Exception — frontend-only PRs"), prompt.indexOf("Exception — frontend-only PRs") + 800);
380
+ expect(exceptionBlock).toContain(".dart");
381
+ expect(exceptionBlock).toContain(".tsx");
382
+ expect(exceptionBlock).toContain(".vue");
383
+ });
363
384
  });
@@ -10,7 +10,7 @@ import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWo
10
10
  import { AnalyticsService } from "../../services/AnalyticsService.js";
11
11
  import { StateManager, registerSession, storeSessionData, setTestsRepoDir, } from "../../utils/AnalysisStateManager.js";
12
12
  import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
13
- import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
13
+ import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
14
14
  import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
15
15
  import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
16
16
  import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
@@ -1123,7 +1123,13 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
1123
1123
  // pass isFrontendFile (any .ts under a frontend directory matches
1124
1124
  // the tier-3 rule) but aren't UI source we'd want to ground page
1125
1125
  // enumeration in.
1126
- const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
1126
+ // Compute hasFlutterSdkDep once and pass it through so .dart files
1127
+ // in a Flutter project are recognised as frontend. See Confluence
1128
+ // "Flutter support in Testbot" — this is the second budget-driving
1129
+ // call site that must thread the flag (the other is
1130
+ // uiAnalyzeChangesTool). Both must agree to avoid silent divergence.
1131
+ const flutterSdk = hasFlutterSdkDep(params.repositoryPath);
1132
+ const frontendFiles = changedFiles.filter((f) => isFrontendFile(f, { hasFlutterSdkDep: flutterSdk }) && !isTestFile(f));
1127
1133
  if (frontendFiles.length === 0)
1128
1134
  return undefined;
1129
1135
  const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
@@ -3,7 +3,7 @@ import * as path from "path";
3
3
  import { z } from "zod";
4
4
  import { logger } from "../../utils/logger.js";
5
5
  import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
6
- import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
6
+ import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
7
7
  import { parseChangedFilesFromDiff } from "../../utils/branchDiff.js";
8
8
  import { toolText } from "../../utils/utils.js";
9
9
  import { isTestbotEnabled } from "../../utils/featureFlags.js";
@@ -49,7 +49,13 @@ export async function runUiAnalyzeChanges(params) {
49
49
  instructions: DIFF_FILE_MISSING_INSTRUCTIONS,
50
50
  };
51
51
  }
52
- const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
52
+ // Compute hasFlutterSdkDep once at the tool boundary; pass into isFrontendFile
53
+ // so .dart files are recognised as frontend in Flutter repos. See Confluence
54
+ // "Flutter support in Testbot" — this is one of two budget-driving call sites
55
+ // that must thread the flag (the other is analyzeChangesTool). Without it,
56
+ // a Flutter PR shows zero frontend files and never enters the UI pipeline.
57
+ const flutterSdk = hasFlutterSdkDep(repoPath);
58
+ const frontendFiles = changedFiles.filter((f) => isFrontendFile(f, { hasFlutterSdkDep: flutterSdk }) && !isTestFile(f));
53
59
  if (frontendFiles.length === 0) {
54
60
  const uiContext = {
55
61
  changedFrontendFiles: [],
@@ -97,4 +97,51 @@ describe("runUiAnalyzeChanges", () => {
97
97
  expect(result.instructions).toMatch(/source-grounded/i);
98
98
  fs.rmSync(repo, { recursive: true, force: true });
99
99
  });
100
+ // Flutter support — the headline case the Confluence plan validates.
101
+ // Without the .dart classifier change, a Flutter PR returns
102
+ // changedFrontendFiles: [] → "No UI changes detected" → agent skips
103
+ // browser_navigate entirely. With the change + a Flutter pubspec, the
104
+ // diff registers as frontend and the agent gets told to navigate.
105
+ it("recognises .dart files as frontend in a Flutter project (with pubspec.yaml)", async () => {
106
+ readSpy.mockResolvedValue({
107
+ services: [
108
+ {
109
+ serviceName: "birdle-frontend",
110
+ language: "typescript",
111
+ framework: "playwright",
112
+ api: { baseUrl: "http://localhost:8080" },
113
+ },
114
+ ],
115
+ });
116
+ const repo = makeRepoWithDiff(["lib/main.dart"]);
117
+ // Write a Flutter pubspec.yaml at the repo root so hasFlutterSdkDep returns true
118
+ fs.writeFileSync(path.join(repo, "pubspec.yaml"), `name: birdle\ndependencies:\n flutter:\n sdk: flutter\n`);
119
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
120
+ expect(result.uiContext.changedFrontendFiles).toContain("lib/main.dart");
121
+ expect(result.uiContext.candidateUiPages.length).toBeGreaterThan(0);
122
+ expect(result.instructions).toMatch(/browser_navigate/);
123
+ fs.rmSync(repo, { recursive: true, force: true });
124
+ });
125
+ it("does NOT recognise .dart files as frontend without a Flutter pubspec.yaml", async () => {
126
+ // Pure Dart project (server/CLI) — pubspec.yaml absent or missing the SDK dep.
127
+ // .dart files should NOT enter the UI pipeline.
128
+ readSpy.mockResolvedValue({ services: [] });
129
+ const repo = makeRepoWithDiff(["bin/server.dart"]);
130
+ // No pubspec.yaml written at all
131
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
132
+ expect(result.uiContext.changedFrontendFiles).toEqual([]);
133
+ expect(result.uiContext.candidateUiPages).toEqual([]);
134
+ expect(result.instructions).toMatch(/no UI changes/i);
135
+ fs.rmSync(repo, { recursive: true, force: true });
136
+ });
137
+ it("does NOT recognise .dart files as frontend in a pure Dart project (pubspec without flutter SDK dep)", async () => {
138
+ // pubspec.yaml exists but declares a Dart server (e.g. shelf) — no flutter SDK
139
+ readSpy.mockResolvedValue({ services: [] });
140
+ const repo = makeRepoWithDiff(["bin/server.dart"]);
141
+ fs.writeFileSync(path.join(repo, "pubspec.yaml"), `name: my_dart_server\ndependencies:\n shelf: ^1.4.0\n`);
142
+ const result = await runUiAnalyzeChanges({ repositoryPath: repo });
143
+ expect(result.uiContext.changedFrontendFiles).toEqual([]);
144
+ expect(result.uiContext.candidateUiPages).toEqual([]);
145
+ fs.rmSync(repo, { recursive: true, force: true });
146
+ });
100
147
  });