sceneview-mcp 3.0.0 → 3.0.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/README.md CHANGED
@@ -1,36 +1,17 @@
1
1
  # sceneview-mcp
2
2
 
3
- MCP server for [SceneView](https://github.com/SceneView/sceneview-android) — 3D and AR with Jetpack Compose for Android.
3
+ [![npm](https://img.shields.io/npm/v/sceneview-mcp?color=cb3837&label=npm)](https://www.npmjs.com/package/sceneview-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
4
5
 
5
- Install this once and Claude always knows how to use SceneView. No copy-pasting docs. No hallucinated APIs.
6
+ MCP server for [SceneView](https://github.com/SceneView/sceneview-android) 3D and AR as Jetpack Compose composables for Android.
6
7
 
7
- ---
8
-
9
- ## What it provides
10
-
11
- **Resource — `sceneview://api`**
12
- The complete SceneView 3.0.0 API reference (llms.txt): composable signatures, node types, AR scope, resource loading, threading rules, common patterns.
13
-
14
- **Tool — `get_sample`**
15
-
16
- | Scenario | What you get |
17
- |---|---|
18
- | `model-viewer` | Full-screen 3D scene, HDR environment, orbit camera |
19
- | `ar-tap-to-place` | AR tap-to-place with pinch-to-scale and drag-to-rotate |
20
- | `ar-placement-cursor` | AR reticle that snaps to surfaces, tap to confirm |
21
- | `ar-augmented-image` | Detect a reference image, overlay a 3D model |
22
- | `ar-face-filter` | Front-camera face mesh with a custom material |
23
-
24
- **Tool — `get_setup`**
25
- Gradle dependency + AndroidManifest for `"3d"` or `"ar"` projects.
8
+ Add this to Claude and it **always knows how to use SceneView**. No copy-pasting docs. No hallucinated APIs. Correct, compilable Kotlin — first try.
26
9
 
27
10
  ---
28
11
 
29
- ## Installation
12
+ ## Quick start
30
13
 
31
- ### Project-level (recommended)
32
-
33
- Add `.claude/mcp.json` at your Android project root:
14
+ Add to your Claude config and you're done:
34
15
 
35
16
  ```json
36
17
  {
@@ -43,112 +24,80 @@ Add `.claude/mcp.json` at your Android project root:
43
24
  }
44
25
  ```
45
26
 
46
- Run `/mcp` in Claude Code to confirm the server is connected.
27
+ <details>
28
+ <summary>Where does this go?</summary>
47
29
 
48
- ### Global (all projects)
30
+ | Client | Config file |
31
+ |---|---|
32
+ | **Claude Code** (project) | `.claude/mcp.json` at project root |
33
+ | **Claude Code** (global) | `~/.claude/mcp.json` |
34
+ | **Claude Desktop** | `claude_desktop_config.json` |
49
35
 
50
- Add to `~/.claude/mcp.json`:
36
+ After saving, run `/mcp` in Claude Code or restart Claude Desktop to pick it up.
37
+ </details>
51
38
 
52
- ```json
53
- {
54
- "mcpServers": {
55
- "sceneview": {
56
- "command": "npx",
57
- "args": ["-y", "sceneview-mcp"]
58
- }
59
- }
60
- }
61
- ```
39
+ ---
62
40
 
63
- ### Claude Desktop
41
+ ## What Claude gets
64
42
 
65
- Add to `claude_desktop_config.json`:
43
+ ### Resource `sceneview://api`
66
44
 
67
- ```json
68
- {
69
- "mcpServers": {
70
- "sceneview": {
71
- "command": "npx",
72
- "args": ["-y", "sceneview-mcp"]
73
- }
74
- }
75
- }
76
- ```
45
+ The complete SceneView 3.0.0 API reference (`llms.txt`): composable signatures, node types, AR scope, resource loading, threading rules, and common patterns.
46
+
47
+ ### Tool — `get_sample(scenario)`
48
+
49
+ Returns a complete, compilable Kotlin sample.
50
+
51
+ | Scenario | What you get |
52
+ |---|---|
53
+ | `model-viewer` | Full-screen 3D scene, HDR environment, orbit camera |
54
+ | `ar-tap-to-place` | AR tap-to-place with pinch-to-scale and drag-to-rotate |
55
+ | `ar-placement-cursor` | AR reticle that snaps to surfaces, tap to confirm |
56
+ | `ar-augmented-image` | Detect a reference image, overlay a 3D model |
57
+ | `ar-face-filter` | Front-camera face mesh with a custom material |
58
+
59
+ ### Tool — `get_setup(type)`
60
+
61
+ Returns Gradle dependencies + AndroidManifest for `"3d"` or `"ar"` projects.
77
62
 
78
63
  ---
79
64
 
80
65
  ## How it works
81
66
 
82
67
  ```
83
- Developer: "add AR placement to my app"
68
+ You: "Add AR placement to my app"
84
69
 
85
-
86
- Claude reads sceneview://api ← full llms.txt, always current
70
+ Claude: reads sceneview://api ← full API ref, always current
87
71
 
88
-
89
- Claude calls get_sample("ar-tap-to-place") ← working Kotlin boilerplate
72
+ Claude: calls get_sample("ar-tap-to-place") ← working Kotlin
90
73
 
91
-
92
- Correct, compilable SceneView 3.0.0 code — first try, zero hallucination
74
+ Result: Correct, compilable SceneView 3.0.0 code
93
75
  ```
94
76
 
95
77
  ---
96
78
 
97
- ## Sample prompts
79
+ ## Try it — sample prompts
98
80
 
99
- ### 3D model viewer
100
- ```
101
- Create an Android Compose screen called ModelViewerScreen that loads
102
- assets/models/my_model.glb in a full-screen 3D scene with orbit camera and HDR
103
- environment from assets/environments/sky_2k.hdr.
104
- Use SceneView io.github.sceneview:sceneview:3.0.0.
105
- ```
81
+ **3D model viewer**
82
+ > Create a Compose screen that loads `models/helmet.glb` in a full-screen 3D scene with orbit camera and HDR environment. Use SceneView 3.0.0.
106
83
 
107
- ### AR tap-to-place
108
- ```
109
- Create an Android Compose AR screen called TapToPlaceScreen. Show a plane
110
- detection grid. Tapping places assets/models/chair.glb on the surface with
111
- pinch-to-scale and drag-to-rotate. Multiple taps = multiple objects.
112
- Use SceneView io.github.sceneview:arsceneview:3.0.0.
113
- ```
84
+ **AR tap-to-place**
85
+ > Create an AR Compose screen. Tapping a detected surface places `models/chair.glb` with pinch-to-scale and drag-to-rotate. Multiple taps = multiple objects.
114
86
 
115
- ### AR placement cursor
116
- ```
117
- Create an AR screen called ARCursorScreen with a reticle that snaps to surfaces
118
- at screen center. Tap to place assets/models/object.glb and hide the reticle.
119
- Use SceneView io.github.sceneview:arsceneview:3.0.0.
120
- ```
87
+ **AR placement cursor**
88
+ > Create an AR screen with a reticle that snaps to surfaces at screen center. Tap to place `models/object.glb` and hide the reticle.
121
89
 
122
- ### AR augmented image
123
- ```
124
- Create an AR screen called AugmentedImageScreen that detects R.drawable.target_image
125
- (15 cm wide) and places assets/models/overlay.glb above it scaled to image width.
126
- Model disappears when tracking is lost.
127
- Use SceneView io.github.sceneview:arsceneview:3.0.0.
128
- ```
90
+ **AR augmented image**
91
+ > Create an AR screen that detects `R.drawable.target_image` (15 cm) and places `models/overlay.glb` above it, scaled to match.
129
92
 
130
- ### AR face filter
131
- ```
132
- Create an AR screen called FaceFilterScreen using the front camera that detects
133
- faces and applies assets/materials/face_mask.filamat to the face mesh.
134
- Use SceneView io.github.sceneview:arsceneview:3.0.0.
135
- ```
93
+ **AR face filter**
94
+ > Create an AR screen using the front camera that detects faces and applies `materials/face_mask.filamat` to the mesh.
136
95
 
137
- ### 3D product configurator
138
- ```
139
- Create a 3D product configurator screen with Red/Blue/Green color buttons.
140
- Apply the selected color as a solid material on assets/models/product.glb.
141
- Add orbit camera and pinch-to-zoom.
142
- Use SceneView io.github.sceneview:sceneview:3.0.0.
143
- ```
96
+ **Product configurator**
97
+ > Create a 3D product configurator with Red/Blue/Green buttons. Apply the color as a material on `models/product.glb`. Add orbit camera and pinch-to-zoom.
144
98
 
145
- ### AR multi-object scene
146
- ```
147
- Create an AR screen where a bottom sheet lets users choose between chair, table,
148
- and lamp GLBs in assets/models/. Tapping places the selected model. Each object
149
- is independently pinch-to-scale and drag-to-rotate. A "Clear all" button removes
150
- everything. Use SceneView io.github.sceneview:arsceneview:3.0.0.
151
- ```
99
+ **AR multi-object scene**
100
+ > Create an AR screen where a bottom sheet lets users pick between chair, table, and lamp GLBs. Tap to place. Each object gets pinch-to-scale and drag-to-rotate.
152
101
 
153
102
  ---
154
103
 
@@ -157,9 +106,9 @@ everything. Use SceneView io.github.sceneview:arsceneview:3.0.0.
157
106
  ```bash
158
107
  cd mcp
159
108
  npm install
160
- npm run prepare # copies ../llms.txt and compiles TypeScript
109
+ npm run prepare # copies llms.txt + compiles TypeScript
161
110
  npm start # run over stdio
162
- npx @modelcontextprotocol/inspector node dist/index.js # test with inspector
111
+ npx @modelcontextprotocol/inspector node dist/index.js # test in MCP inspector
163
112
  ```
164
113
 
165
114
  ## Publishing
@@ -169,3 +118,11 @@ cd mcp
169
118
  npm run prepare
170
119
  npm publish --access public
171
120
  ```
121
+
122
+ ---
123
+
124
+ ## Links
125
+
126
+ - **SDK**: [github.com/SceneView/sceneview-android](https://github.com/SceneView/sceneview-android)
127
+ - **npm**: [npmjs.com/package/sceneview-mcp](https://www.npmjs.com/package/sceneview-mcp)
128
+ - **MCP spec**: [modelcontextprotocol.io](https://modelcontextprotocol.io)
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@ import { readFileSync } from "fs";
6
6
  import { dirname, resolve } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { getSample, SAMPLE_IDS, SAMPLES } from "./samples.js";
9
+ import { validateCode, formatValidationReport } from "./validator.js";
10
+ import { MIGRATION_GUIDE } from "./migration.js";
11
+ import { fetchKnownIssues } from "./issues.js";
9
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
13
  let API_DOCS;
11
14
  try {
@@ -14,7 +17,8 @@ try {
14
17
  catch {
15
18
  API_DOCS = "SceneView API docs not found. Run `npm run prepare` to bundle llms.txt.";
16
19
  }
17
- const server = new Server({ name: "@sceneview/mcp", version: "3.0.0" }, { capabilities: { resources: {}, tools: {} } });
20
+ const server = new Server({ name: "@sceneview/mcp", version: "3.0.2" }, { capabilities: { resources: {}, tools: {} } });
21
+ // ─── Resources ───────────────────────────────────────────────────────────────
18
22
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
19
23
  resources: [
20
24
  {
@@ -23,21 +27,36 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
23
27
  description: "Complete SceneView 3.0.0 API — Scene, ARScene, SceneScope DSL, ARSceneScope DSL, node types, resource loading, camera, gestures, math types, threading rules, and common patterns. Read this before writing any SceneView code.",
24
28
  mimeType: "text/markdown",
25
29
  },
30
+ {
31
+ uri: "sceneview://known-issues",
32
+ name: "SceneView Open GitHub Issues",
33
+ description: "Live list of open issues from the SceneView GitHub repository. Check this before reporting a bug or when something isn't working — there may already be a known workaround.",
34
+ mimeType: "text/markdown",
35
+ },
26
36
  ],
27
37
  }));
28
38
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
29
- if (request.params.uri === "sceneview://api") {
30
- return {
31
- contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
32
- };
39
+ switch (request.params.uri) {
40
+ case "sceneview://api":
41
+ return {
42
+ contents: [{ uri: "sceneview://api", mimeType: "text/markdown", text: API_DOCS }],
43
+ };
44
+ case "sceneview://known-issues": {
45
+ const issues = await fetchKnownIssues();
46
+ return {
47
+ contents: [{ uri: "sceneview://known-issues", mimeType: "text/markdown", text: issues }],
48
+ };
49
+ }
50
+ default:
51
+ throw new Error(`Unknown resource: ${request.params.uri}`);
33
52
  }
34
- throw new Error(`Unknown resource: ${request.params.uri}`);
35
53
  });
54
+ // ─── Tools ───────────────────────────────────────────────────────────────────
36
55
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
37
56
  tools: [
38
57
  {
39
58
  name: "get_sample",
40
- description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising.",
59
+ description: "Returns a complete, compilable Kotlin sample for a given SceneView scenario. Use this to get a working starting point before customising. Call `list_samples` first if you are unsure which scenario fits.",
41
60
  inputSchema: {
42
61
  type: "object",
43
62
  properties: {
@@ -50,6 +69,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
50
69
  required: ["scenario"],
51
70
  },
52
71
  },
72
+ {
73
+ name: "list_samples",
74
+ description: "Lists all available SceneView code samples with their IDs, descriptions, and tags. Use this to find the right sample before calling `get_sample`, or to show the user what SceneView can do.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ tag: {
79
+ type: "string",
80
+ description: "Optional tag to filter by (e.g. \"ar\", \"3d\", \"anchor\", \"face-tracking\", \"geometry\", \"animation\"). Omit to list all samples.",
81
+ },
82
+ },
83
+ required: [],
84
+ },
85
+ },
53
86
  {
54
87
  name: "get_setup",
55
88
  description: "Returns the Gradle dependency and AndroidManifest snippet required to use SceneView in an Android project.",
@@ -65,16 +98,46 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
65
98
  required: ["type"],
66
99
  },
67
100
  },
101
+ {
102
+ name: "validate_code",
103
+ description: "Checks a Kotlin SceneView snippet for common mistakes: threading violations, wrong destroy order, missing null-checks on rememberModelInstance, LightNode trailing-lambda bug, deprecated 2.x APIs, and more. Always call this before presenting generated SceneView code to the user.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ code: {
108
+ type: "string",
109
+ description: "The Kotlin source code to validate (composable function, class, or file).",
110
+ },
111
+ },
112
+ required: ["code"],
113
+ },
114
+ },
115
+ {
116
+ name: "get_migration_guide",
117
+ description: "Returns the full SceneView 2.x → 3.0 migration guide. Use this when a user reports code that worked in 2.x but breaks in 3.0, or when helping someone upgrade.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {},
121
+ required: [],
122
+ },
123
+ },
68
124
  ],
69
125
  }));
126
+ // ─── Tool handlers ────────────────────────────────────────────────────────────
70
127
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
71
128
  switch (request.params.name) {
129
+ // ── get_sample ────────────────────────────────────────────────────────────
72
130
  case "get_sample": {
73
131
  const scenario = request.params.arguments?.scenario;
74
132
  const sample = getSample(scenario);
75
133
  if (!sample) {
76
134
  return {
77
- content: [{ type: "text", text: `Unknown scenario "${scenario}". Available: ${SAMPLE_IDS.join(", ")}` }],
135
+ content: [
136
+ {
137
+ type: "text",
138
+ text: `Unknown scenario "${scenario}". Call \`list_samples\` to see available options.`,
139
+ },
140
+ ],
78
141
  isError: true,
79
142
  };
80
143
  }
@@ -85,6 +148,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
85
148
  text: [
86
149
  `## ${sample.title}`,
87
150
  ``,
151
+ `**Tags:** ${sample.tags.join(", ")}`,
152
+ ``,
88
153
  `**Gradle dependency:**`,
89
154
  `\`\`\`kotlin`,
90
155
  `implementation("${sample.dependency}")`,
@@ -102,6 +167,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
102
167
  ],
103
168
  };
104
169
  }
170
+ // ── list_samples ──────────────────────────────────────────────────────────
171
+ case "list_samples": {
172
+ const filterTag = request.params.arguments?.tag;
173
+ const entries = Object.values(SAMPLES).filter((s) => !filterTag || s.tags.includes(filterTag));
174
+ if (entries.length === 0) {
175
+ return {
176
+ content: [
177
+ {
178
+ type: "text",
179
+ text: `No samples found with tag "${filterTag}". Available tags: 3d, ar, model, geometry, animation, camera, environment, anchor, plane-detection, image-tracking, face-tracking, placement, gestures`,
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ const header = filterTag
185
+ ? `## SceneView samples tagged \`${filterTag}\` (${entries.length})\n`
186
+ : `## All SceneView samples (${entries.length})\n`;
187
+ const rows = entries
188
+ .map((s) => `### \`${s.id}\`\n**${s.title}**\n${s.description}\n*Tags:* ${s.tags.join(", ")}\n*Dependency:* \`${s.dependency}\`\n\nCall \`get_sample("${s.id}")\` for the full code.`)
189
+ .join("\n\n---\n\n");
190
+ return { content: [{ type: "text", text: header + rows }] };
191
+ }
192
+ // ── get_setup ─────────────────────────────────────────────────────────────
105
193
  case "get_setup": {
106
194
  const type = request.params.arguments?.type;
107
195
  if (type === "3d") {
@@ -158,6 +246,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
158
246
  isError: true,
159
247
  };
160
248
  }
249
+ // ── validate_code ─────────────────────────────────────────────────────────
250
+ case "validate_code": {
251
+ const code = request.params.arguments?.code;
252
+ if (!code || typeof code !== "string") {
253
+ return {
254
+ content: [{ type: "text", text: "Missing required parameter: `code`" }],
255
+ isError: true,
256
+ };
257
+ }
258
+ const issues = validateCode(code);
259
+ const report = formatValidationReport(issues);
260
+ return { content: [{ type: "text", text: report }] };
261
+ }
262
+ // ── get_migration_guide ───────────────────────────────────────────────────
263
+ case "get_migration_guide": {
264
+ return { content: [{ type: "text", text: MIGRATION_GUIDE }] };
265
+ }
161
266
  default:
162
267
  return {
163
268
  content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
package/dist/issues.js ADDED
@@ -0,0 +1,72 @@
1
+ // Cache for 10 minutes — avoids hammering the GitHub API on every tool call
2
+ const CACHE_TTL_MS = 10 * 60 * 1000;
3
+ let cache = null;
4
+ export async function fetchKnownIssues() {
5
+ const now = Date.now();
6
+ if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
7
+ return cache.data;
8
+ }
9
+ let issues = [];
10
+ let fetchError = null;
11
+ try {
12
+ const response = await fetch("https://api.github.com/repos/SceneView/sceneview-android/issues?state=open&per_page=30", {
13
+ headers: {
14
+ Accept: "application/vnd.github+json",
15
+ "User-Agent": "sceneview-mcp/3.0",
16
+ "X-GitHub-Api-Version": "2022-11-28",
17
+ },
18
+ });
19
+ if (!response.ok) {
20
+ fetchError = `GitHub API returned ${response.status}: ${response.statusText}`;
21
+ }
22
+ else {
23
+ issues = (await response.json());
24
+ }
25
+ }
26
+ catch (err) {
27
+ fetchError = `Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`;
28
+ }
29
+ const result = formatIssues(issues, fetchError);
30
+ cache = { data: result, fetchedAt: now };
31
+ return result;
32
+ }
33
+ function formatIssues(issues, fetchError) {
34
+ const lines = ["# SceneView — Open GitHub Issues\n"];
35
+ if (fetchError) {
36
+ lines.push(`> ⚠️ ${fetchError}. Showing cached data if available.\n`);
37
+ }
38
+ else {
39
+ lines.push(`*Fetched live from GitHub — ${issues.length} open issue(s). Cached for 10 minutes.*\n`);
40
+ }
41
+ if (issues.length === 0) {
42
+ lines.push("No open issues. 🎉");
43
+ return lines.join("\n");
44
+ }
45
+ // Group by label: bugs first, then questions/other
46
+ const bugs = issues.filter((i) => i.labels.some((l) => l.name === "bug"));
47
+ const others = issues.filter((i) => !i.labels.some((l) => l.name === "bug"));
48
+ const renderGroup = (title, group) => {
49
+ if (group.length === 0)
50
+ return;
51
+ lines.push(`## ${title}\n`);
52
+ for (const issue of group) {
53
+ const labelStr = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : "";
54
+ lines.push(`### #${issue.number} — ${issue.title}${labelStr}`);
55
+ lines.push(`*Opened by @${issue.user.login} · ${issue.updated_at.slice(0, 10)}*`);
56
+ lines.push(issue.html_url);
57
+ // Include first 300 chars of body as context
58
+ if (issue.body) {
59
+ const excerpt = issue.body
60
+ .replace(/\r\n/g, "\n")
61
+ .replace(/```[\s\S]*?```/g, "[code block]") // strip code blocks for brevity
62
+ .trim()
63
+ .slice(0, 300);
64
+ lines.push(`\n> ${excerpt.replace(/\n/g, "\n> ")}${issue.body.length > 300 ? "…" : ""}`);
65
+ }
66
+ lines.push("");
67
+ }
68
+ };
69
+ renderGroup("🐛 Bug Reports", bugs);
70
+ renderGroup("📋 Other Issues", others);
71
+ return lines.join("\n");
72
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ // We test the private formatIssues logic by calling fetchKnownIssues with a
3
+ // mocked global fetch so we never hit the real GitHub API in tests.
4
+ const mockIssues = [
5
+ {
6
+ number: 123,
7
+ title: "SIGABRT on dispose",
8
+ html_url: "https://github.com/SceneView/sceneview-android/issues/123",
9
+ body: "Calling destroy() causes a native crash.",
10
+ labels: [{ name: "bug" }],
11
+ created_at: "2026-01-01T00:00:00Z",
12
+ updated_at: "2026-01-15T00:00:00Z",
13
+ user: { login: "testuser" },
14
+ },
15
+ {
16
+ number: 124,
17
+ title: "How do I add shadows?",
18
+ html_url: "https://github.com/SceneView/sceneview-android/issues/124",
19
+ body: "I want to enable shadows for my AR scene.",
20
+ labels: [{ name: "question" }],
21
+ created_at: "2026-01-02T00:00:00Z",
22
+ updated_at: "2026-01-16T00:00:00Z",
23
+ user: { login: "anotheruser" },
24
+ },
25
+ ];
26
+ beforeEach(() => {
27
+ // Reset module cache so the in-memory cache is cleared between tests
28
+ vi.resetModules();
29
+ });
30
+ describe("fetchKnownIssues", () => {
31
+ it("returns markdown with issue titles when GitHub API succeeds", async () => {
32
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
33
+ ok: true,
34
+ json: async () => mockIssues,
35
+ }));
36
+ const { fetchKnownIssues } = await import("./issues.js");
37
+ const result = await fetchKnownIssues();
38
+ expect(result).toContain("SIGABRT on dispose");
39
+ expect(result).toContain("#123");
40
+ expect(result).toContain("How do I add shadows?");
41
+ expect(result).toContain("#124");
42
+ vi.unstubAllGlobals();
43
+ });
44
+ it("groups bug-labelled issues under Bug Reports", async () => {
45
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
46
+ ok: true,
47
+ json: async () => mockIssues,
48
+ }));
49
+ const { fetchKnownIssues } = await import("./issues.js");
50
+ const result = await fetchKnownIssues();
51
+ expect(result).toContain("Bug Reports");
52
+ expect(result).toContain("SIGABRT on dispose");
53
+ vi.unstubAllGlobals();
54
+ });
55
+ it("groups non-bug issues under Other Issues", async () => {
56
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ json: async () => mockIssues,
59
+ }));
60
+ const { fetchKnownIssues } = await import("./issues.js");
61
+ const result = await fetchKnownIssues();
62
+ expect(result).toContain("Other Issues");
63
+ expect(result).toContain("How do I add shadows?");
64
+ vi.unstubAllGlobals();
65
+ });
66
+ it("includes github URLs", async () => {
67
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
68
+ ok: true,
69
+ json: async () => mockIssues,
70
+ }));
71
+ const { fetchKnownIssues } = await import("./issues.js");
72
+ const result = await fetchKnownIssues();
73
+ expect(result).toContain("https://github.com/SceneView/sceneview-android/issues/123");
74
+ vi.unstubAllGlobals();
75
+ });
76
+ it("includes body excerpt", async () => {
77
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ json: async () => mockIssues,
80
+ }));
81
+ const { fetchKnownIssues } = await import("./issues.js");
82
+ const result = await fetchKnownIssues();
83
+ expect(result).toContain("native crash");
84
+ vi.unstubAllGlobals();
85
+ });
86
+ it("shows warning message when GitHub API fails", async () => {
87
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
88
+ ok: false,
89
+ status: 403,
90
+ statusText: "Forbidden",
91
+ }));
92
+ const { fetchKnownIssues } = await import("./issues.js");
93
+ const result = await fetchKnownIssues();
94
+ expect(result).toContain("403");
95
+ vi.unstubAllGlobals();
96
+ });
97
+ it("shows warning message when fetch throws (network error)", async () => {
98
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
99
+ const { fetchKnownIssues } = await import("./issues.js");
100
+ const result = await fetchKnownIssues();
101
+ expect(result).toContain("Network error");
102
+ vi.unstubAllGlobals();
103
+ });
104
+ it("shows celebration when there are no open issues", async () => {
105
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
106
+ ok: true,
107
+ json: async () => [],
108
+ }));
109
+ const { fetchKnownIssues } = await import("./issues.js");
110
+ const result = await fetchKnownIssues();
111
+ expect(result).toContain("No open issues");
112
+ vi.unstubAllGlobals();
113
+ });
114
+ });