openuispec 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +42 -3
  2. package/cli/init.ts +2 -0
  3. package/examples/social-app/AGENTS.md +1 -1
  4. package/examples/social-app/CLAUDE.md +1 -1
  5. package/examples/social-app/generated/android/social-app/app/.paparazzi-hashes.json +3 -0
  6. package/examples/social-app/generated/android/social-app/app/build.gradle.kts +2 -0
  7. package/examples/social-app/generated/android/social-app/app/src/main/java/com/social/app/ui/screens/HomeFeedScreen.kt +12 -0
  8. package/examples/social-app/generated/android/social-app/app/src/test/kotlin/com/social/app/screenshots/HomeFeedScreenshotTest.kt +34 -0
  9. package/examples/social-app/generated/android/social-app/build.gradle.kts +1 -0
  10. package/examples/social-app/generated/android/social-app/gradle/libs.versions.toml +2 -0
  11. package/examples/social-app/generated/android/social-app/gradle/wrapper/gradle-wrapper.jar +0 -0
  12. package/examples/social-app/generated/android/social-app/gradlew +239 -16
  13. package/examples/social-app/generated/android/social-app/settings.gradle.kts +4 -0
  14. package/examples/todo-orbit/generated/ios/Todo Orbit/.screenshot-uitest/Sources/ScreenshotUITest.swift +36 -0
  15. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +204 -212
  16. package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +1 -0
  17. package/mcp-server/index.ts +64 -1
  18. package/mcp-server/screenshot-android.ts +462 -0
  19. package/mcp-server/screenshot-ios.ts +541 -0
  20. package/mcp-server/screenshot-shared.ts +200 -0
  21. package/mcp-server/screenshot.ts +15 -1
  22. package/package.json +3 -2
  23. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +0 -79
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Shared utilities for platform screenshot tools (web, android, ios).
3
+ */
4
+
5
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
6
+ import { join, resolve, relative } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import YAML from "yaml";
9
+ import { findProjectDir } from "../drift/index.js";
10
+
11
+ // ── shared result type ──────────────────────────────────────────────
12
+
13
+ export interface ScreenshotResult {
14
+ content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
15
+ isError?: true;
16
+ }
17
+
18
+ // ── manifest loading ────────────────────────────────────────────────
19
+
20
+ export interface ManifestInfo {
21
+ projectDir: string;
22
+ projectRoot: string;
23
+ manifest: any;
24
+ projectName: string;
25
+ }
26
+
27
+ export function loadManifest(projectCwd: string): ManifestInfo {
28
+ const projectDir = findProjectDir(projectCwd);
29
+ const manifestPath = join(projectDir, "openuispec.yaml");
30
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
31
+ const projectName = manifest.project?.name ?? "app";
32
+ const projectRoot = resolve(projectDir, "..");
33
+ return { projectDir, projectRoot, manifest, projectName };
34
+ }
35
+
36
+ // ── generic platform app directory discovery ────────────────────────
37
+
38
+ function tryFindProjectDir(cwd: string): string | null {
39
+ if (existsSync(join(cwd, "openuispec.yaml"))) {
40
+ return cwd;
41
+ }
42
+ try { return findProjectDir(cwd); } catch { return null; }
43
+ }
44
+
45
+ export function findPlatformAppDir(
46
+ projectCwd: string,
47
+ platform: "web" | "android" | "ios",
48
+ existsCheck: (dir: string) => boolean,
49
+ directDir?: string,
50
+ ): string {
51
+ const label = platform.charAt(0).toUpperCase() + platform.slice(1);
52
+
53
+ // If a direct project dir is provided, use it without manifest lookup
54
+ if (directDir) {
55
+ const resolved = resolve(directDir);
56
+ if (existsCheck(resolved)) return resolved;
57
+ throw new Error(`${label} project not found at provided path: ${resolved}`);
58
+ }
59
+
60
+ // Check if projectCwd has an openuispec.yaml — if so, use manifest-based discovery
61
+ const manifestDir = tryFindProjectDir(projectCwd);
62
+
63
+ if (manifestDir) {
64
+ const manifestPath = join(manifestDir, "openuispec.yaml");
65
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
66
+ const projectName = manifest.project?.name ?? "app";
67
+ const projectRoot = resolve(manifestDir, "..");
68
+
69
+ // Check custom output_dir first
70
+ const customDir = manifest.generation?.output_dir?.[platform];
71
+ if (customDir) {
72
+ const resolved = resolve(manifestDir, customDir);
73
+ if (existsCheck(resolved)) return resolved;
74
+ }
75
+
76
+ // Default: generated/<platform>/<project-name>/
77
+ const defaultDir = join(projectRoot, "generated", platform, projectName);
78
+ if (existsCheck(defaultDir)) return defaultDir;
79
+
80
+ throw new Error(
81
+ `${label} app not found. Checked:\n` +
82
+ (customDir ? ` - ${resolve(manifestDir, customDir)}\n` : "") +
83
+ ` - ${defaultDir}\n` +
84
+ `Generate the ${platform} target first, then try again.`,
85
+ );
86
+ }
87
+
88
+ // No manifest — treat projectCwd itself as the platform project dir
89
+ const resolved = resolve(projectCwd);
90
+ if (existsCheck(resolved)) return resolved;
91
+
92
+ throw new Error(
93
+ `${label} project not found at ${resolved}. ` +
94
+ `Provide a project_dir parameter or create an openuispec.yaml manifest.`,
95
+ );
96
+ }
97
+
98
+ // ── file hashing / caching ──────────────────────────────────────────
99
+
100
+ export function hashContent(content: string): string {
101
+ return createHash("md5").update(content).digest("hex");
102
+ }
103
+
104
+ export function loadHashes(dir: string, hashFile: string): Record<string, string> {
105
+ const hashPath = join(dir, hashFile);
106
+ try {
107
+ return JSON.parse(readFileSync(hashPath, "utf-8"));
108
+ } catch {
109
+ return {};
110
+ }
111
+ }
112
+
113
+ export function saveHashes(dir: string, hashFile: string, hashes: Record<string, string>): void {
114
+ writeFileSync(join(dir, hashFile), JSON.stringify(hashes, null, 2));
115
+ }
116
+
117
+ // ── screen name filter ──────────────────────────────────────────────
118
+
119
+ export function matchesScreenFilter(screenName: string, filter: string): boolean {
120
+ const normalizedFilter = filter.toLowerCase().replace(/_/g, "");
121
+ const normalizedScreen = screenName.toLowerCase().replace(/_/g, "");
122
+ return normalizedScreen.includes(normalizedFilter) || normalizedFilter.includes(normalizedScreen);
123
+ }
124
+
125
+ // ── recursive file walker ───────────────────────────────────────────
126
+
127
+ export function walkFiles(
128
+ dir: string,
129
+ ext: string,
130
+ skipDirs: string[] = [],
131
+ ): string[] {
132
+ const results: string[] = [];
133
+ if (!existsSync(dir)) return results;
134
+
135
+ const walk = (d: string) => {
136
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
137
+ const fullPath = join(d, entry.name);
138
+ if (entry.isDirectory()) {
139
+ if (!skipDirs.includes(entry.name)) walk(fullPath);
140
+ } else if (entry.name.endsWith(ext)) {
141
+ results.push(fullPath);
142
+ }
143
+ }
144
+ };
145
+
146
+ walk(dir);
147
+ return results;
148
+ }
149
+
150
+ // ── PNG snapshot collector ──────────────────────────────────────────
151
+
152
+ export function collectPngSnapshots(
153
+ dirs: string[],
154
+ rootDir: string,
155
+ screenFilter: string | undefined,
156
+ nameExtractor: (filename: string) => string,
157
+ ): Array<{ screen: string; path: string; data: string }> {
158
+ const snapshots: Array<{ screen: string; path: string; data: string }> = [];
159
+ const seen = new Set<string>();
160
+
161
+ for (const dir of dirs) {
162
+ const pngs = walkFiles(dir, ".png");
163
+ for (const fullPath of pngs) {
164
+ const filename = fullPath.split("/").pop()!;
165
+ const screen = nameExtractor(filename);
166
+
167
+ if (screenFilter && !matchesScreenFilter(screen, screenFilter)) continue;
168
+
169
+ const key = relative(rootDir, fullPath);
170
+ if (seen.has(key)) continue;
171
+ seen.add(key);
172
+
173
+ const data = readFileSync(fullPath).toString("base64");
174
+ snapshots.push({ screen, path: key, data });
175
+ }
176
+ }
177
+
178
+ return snapshots;
179
+ }
180
+
181
+ // ── screenshot response builder ─────────────────────────────────────
182
+
183
+ export function buildScreenshotResponse(
184
+ snapshots: Array<{ screen: string; path: string; data: string }>,
185
+ metadataFn: (snapshot: { screen: string; path: string }) => Record<string, unknown>,
186
+ ): ScreenshotResult {
187
+ if (snapshots.length === 0) {
188
+ return {
189
+ content: [{ type: "text", text: "No screenshots generated. Check build output." }],
190
+ isError: true,
191
+ };
192
+ }
193
+
194
+ const content: ScreenshotResult["content"] = [];
195
+ for (const snapshot of snapshots) {
196
+ content.push({ type: "image" as const, data: snapshot.data, mimeType: "image/png" });
197
+ content.push({ type: "text" as const, text: JSON.stringify(metadataFn(snapshot), null, 2) });
198
+ }
199
+ return { content };
200
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { spawn, type ChildProcess, execSync } from "node:child_process";
9
- import { existsSync, readFileSync } from "node:fs";
9
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join, resolve } from "node:path";
11
11
  import { createServer, type AddressInfo } from "node:net";
12
12
  import YAML from "yaml";
@@ -21,6 +21,7 @@ export interface ScreenshotOptions {
21
21
  wait_for?: number;
22
22
  full_page?: boolean;
23
23
  selector?: string;
24
+ output_dir?: string;
24
25
  }
25
26
 
26
27
  export interface ScreenshotResult {
@@ -180,6 +181,7 @@ export async function takeScreenshot(
180
181
  wait_for = 1000,
181
182
  full_page = false,
182
183
  selector,
184
+ output_dir,
183
185
  } = options;
184
186
 
185
187
  // 1. Find and start
@@ -223,6 +225,17 @@ export async function takeScreenshot(
223
225
 
224
226
  const base64 = buffer.toString("base64");
225
227
 
228
+ // Save to output_dir if specified
229
+ let savedPath: string | undefined;
230
+ if (output_dir) {
231
+ const outDir = resolve(webDir, output_dir);
232
+ mkdirSync(outDir, { recursive: true });
233
+ const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
234
+ const themeLabel = theme ?? "default";
235
+ savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
236
+ writeFileSync(savedPath, buffer);
237
+ }
238
+
226
239
  return {
227
240
  content: [
228
241
  { type: "image" as const, data: base64, mimeType: "image/png" },
@@ -235,6 +248,7 @@ export async function takeScreenshot(
235
248
  theme: theme ?? "default",
236
249
  full_page,
237
250
  selector: selector ?? null,
251
+ path: savedPath ?? null,
238
252
  }, null, 2),
239
253
  },
240
254
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -42,7 +42,8 @@
42
42
  "drift:snapshot": "tsx drift/index.ts --snapshot --target",
43
43
  "prepare:target": "tsx prepare/index.ts",
44
44
  "status": "tsx status/index.ts",
45
- "postinstall": "echo \"\\n ✓ openuispec installed — if upgrading, run: openuispec update-rules\\n\""
45
+ "postinstall": "echo \"\\n ✓ openuispec installed — if upgrading, run: openuispec update-rules\\n\"",
46
+ "cloc": "cloc --exclude-dir=$(tr '\n' ',' < .cloc-ignore) ."
46
47
  },
47
48
  "dependencies": {
48
49
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -1,79 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <Scheme
3
- LastUpgradeVersion = "2600"
4
- version = "1.7">
5
- <BuildAction
6
- parallelizeBuildables = "YES"
7
- buildImplicitDependencies = "YES"
8
- buildArchitectures = "Automatic">
9
- <BuildActionEntries>
10
- <BuildActionEntry
11
- buildForTesting = "YES"
12
- buildForRunning = "YES"
13
- buildForProfiling = "YES"
14
- buildForArchiving = "YES"
15
- buildForAnalyzing = "YES">
16
- <BuildableReference
17
- BuildableIdentifier = "primary"
18
- BlueprintIdentifier = "E00000010000000000000000"
19
- BuildableName = "TodoOrbit.app"
20
- BlueprintName = "TodoOrbit"
21
- ReferencedContainer = "container:TodoOrbit.xcodeproj">
22
- </BuildableReference>
23
- </BuildActionEntry>
24
- </BuildActionEntries>
25
- </BuildAction>
26
- <TestAction
27
- buildConfiguration = "Debug"
28
- selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29
- selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30
- shouldUseLaunchSchemeArgsEnv = "YES">
31
- <Testables>
32
- </Testables>
33
- </TestAction>
34
- <LaunchAction
35
- buildConfiguration = "Debug"
36
- selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
37
- selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
38
- launchStyle = "0"
39
- useCustomWorkingDirectory = "NO"
40
- ignoresPersistentStateOnLaunch = "NO"
41
- debugDocumentVersioning = "YES"
42
- debugServiceExtension = "internal"
43
- allowLocationSimulation = "YES">
44
- <BuildableProductRunnable
45
- runnableDebuggingMode = "0">
46
- <BuildableReference
47
- BuildableIdentifier = "primary"
48
- BlueprintIdentifier = "E00000010000000000000000"
49
- BuildableName = "TodoOrbit.app"
50
- BlueprintName = "TodoOrbit"
51
- ReferencedContainer = "container:TodoOrbit.xcodeproj">
52
- </BuildableReference>
53
- </BuildableProductRunnable>
54
- </LaunchAction>
55
- <ProfileAction
56
- buildConfiguration = "Release"
57
- shouldUseLaunchSchemeArgsEnv = "YES"
58
- savedToolIdentifier = ""
59
- useCustomWorkingDirectory = "NO"
60
- debugDocumentVersioning = "YES">
61
- <BuildableProductRunnable
62
- runnableDebuggingMode = "0">
63
- <BuildableReference
64
- BuildableIdentifier = "primary"
65
- BlueprintIdentifier = "E00000010000000000000000"
66
- BuildableName = "TodoOrbit.app"
67
- BlueprintName = "TodoOrbit"
68
- ReferencedContainer = "container:TodoOrbit.xcodeproj">
69
- </BuildableReference>
70
- </BuildableProductRunnable>
71
- </ProfileAction>
72
- <AnalyzeAction
73
- buildConfiguration = "Debug">
74
- </AnalyzeAction>
75
- <ArchiveAction
76
- buildConfiguration = "Release"
77
- revealArchiveInOrganizer = "YES">
78
- </ArchiveAction>
79
- </Scheme>