openuispec 0.2.18 → 0.2.20
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 +2 -10
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/docs/images/how-it-works.svg +56 -0
- package/docs/images/workflows.svg +76 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/docs/images/how-it-works-dark.png +0 -0
- package/docs/images/how-it-works-light.png +0 -0
- package/docs/images/workflows-dark.png +0 -0
- package/docs/images/workflows-light.png +0 -0
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preview.ts — Orchestrates spec loading, mock data, and HTML rendering
|
|
3
|
+
* for the openuispec_preview tool.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
import { findProjectDir } from "../drift/index.js";
|
|
9
|
+
import { getBrowser } from "./screenshot-shared.js";
|
|
10
|
+
import { renderPage } from "./preview-render.js";
|
|
11
|
+
// ── viewport defaults per size class ────────────────────────────────
|
|
12
|
+
const SIZE_CLASS_VIEWPORTS = {
|
|
13
|
+
compact: { width: 390, height: 844 },
|
|
14
|
+
regular: { width: 820, height: 1180 },
|
|
15
|
+
expanded: { width: 1280, height: 800 },
|
|
16
|
+
};
|
|
17
|
+
// ── spec loading helpers ────────────────────────────────────────────
|
|
18
|
+
function loadManifest(projectDir) {
|
|
19
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
20
|
+
if (!existsSync(manifestPath)) {
|
|
21
|
+
throw new Error(`Manifest not found at ${manifestPath}`);
|
|
22
|
+
}
|
|
23
|
+
return YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
24
|
+
}
|
|
25
|
+
function loadScreen(projectDir, manifest, screenName) {
|
|
26
|
+
const screensDir = resolve(projectDir, manifest.includes?.screens ?? "./screens/");
|
|
27
|
+
// Try exact filename first
|
|
28
|
+
const candidates = [
|
|
29
|
+
join(screensDir, `${screenName}.yaml`),
|
|
30
|
+
join(screensDir, `${screenName}.yml`),
|
|
31
|
+
];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
if (existsSync(candidate)) {
|
|
34
|
+
return YAML.parse(readFileSync(candidate, "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Scan all screen files for matching screen name key
|
|
38
|
+
if (existsSync(screensDir)) {
|
|
39
|
+
for (const file of readdirSync(screensDir)) {
|
|
40
|
+
if (!file.endsWith(".yaml") && !file.endsWith(".yml"))
|
|
41
|
+
continue;
|
|
42
|
+
const content = YAML.parse(readFileSync(join(screensDir, file), "utf-8"));
|
|
43
|
+
if (content && typeof content === "object" && screenName in content) {
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Screen "${screenName}" not found in ${screensDir}. ` +
|
|
49
|
+
`Available: ${existsSync(screensDir) ? readdirSync(screensDir).filter(f => f.endsWith(".yaml")).map(f => f.replace(".yaml", "")).join(", ") : "none"}`);
|
|
50
|
+
}
|
|
51
|
+
function loadAllTokens(projectDir, manifest) {
|
|
52
|
+
const tokensDir = resolve(projectDir, manifest.includes?.tokens ?? "./tokens/");
|
|
53
|
+
const tokens = {};
|
|
54
|
+
if (!existsSync(tokensDir))
|
|
55
|
+
return tokens;
|
|
56
|
+
for (const file of readdirSync(tokensDir)) {
|
|
57
|
+
if (!file.endsWith(".yaml") && !file.endsWith(".yml"))
|
|
58
|
+
continue;
|
|
59
|
+
const category = file.replace(/\.ya?ml$/, "");
|
|
60
|
+
try {
|
|
61
|
+
tokens[category] = YAML.parse(readFileSync(join(tokensDir, file), "utf-8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Skip malformed token files
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
function loadLocale(projectDir, manifest, localeName) {
|
|
70
|
+
const localesDir = resolve(projectDir, manifest.includes?.locales ?? "./locales/");
|
|
71
|
+
const candidates = [
|
|
72
|
+
join(localesDir, `${localeName}.json`),
|
|
73
|
+
join(localesDir, `${localeName}.yaml`),
|
|
74
|
+
join(localesDir, `${localeName}.yml`),
|
|
75
|
+
];
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (existsSync(candidate)) {
|
|
78
|
+
const content = readFileSync(candidate, "utf-8");
|
|
79
|
+
if (candidate.endsWith(".json")) {
|
|
80
|
+
return JSON.parse(content);
|
|
81
|
+
}
|
|
82
|
+
return YAML.parse(content) ?? {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Fallback to default locale
|
|
86
|
+
const defaultLocale = manifest.i18n?.default_locale ?? "en";
|
|
87
|
+
if (localeName !== defaultLocale) {
|
|
88
|
+
return loadLocale(projectDir, manifest, defaultLocale);
|
|
89
|
+
}
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
function loadContractDefs(projectDir, manifest) {
|
|
93
|
+
const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? "./contracts/");
|
|
94
|
+
const defs = {};
|
|
95
|
+
if (!existsSync(contractsDir))
|
|
96
|
+
return defs;
|
|
97
|
+
for (const file of readdirSync(contractsDir)) {
|
|
98
|
+
if (!file.endsWith(".yaml") && !file.endsWith(".yml"))
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
const content = YAML.parse(readFileSync(join(contractsDir, file), "utf-8"));
|
|
102
|
+
if (content && typeof content === "object") {
|
|
103
|
+
const name = file.replace(/\.ya?ml$/, "");
|
|
104
|
+
defs[name] = content;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Skip malformed contract files
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return defs;
|
|
112
|
+
}
|
|
113
|
+
function loadComponentDefs(projectDir, manifest) {
|
|
114
|
+
const componentsDir = resolve(projectDir, manifest.includes?.components ?? "./components/");
|
|
115
|
+
const defs = {};
|
|
116
|
+
if (!existsSync(componentsDir))
|
|
117
|
+
return defs;
|
|
118
|
+
for (const file of readdirSync(componentsDir)) {
|
|
119
|
+
if (!file.endsWith(".yaml") && !file.endsWith(".yml"))
|
|
120
|
+
continue;
|
|
121
|
+
try {
|
|
122
|
+
const content = YAML.parse(readFileSync(join(componentsDir, file), "utf-8"));
|
|
123
|
+
if (content && typeof content === "object") {
|
|
124
|
+
for (const [name, def] of Object.entries(content)) {
|
|
125
|
+
defs[name] = def;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Skip malformed component files
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return defs;
|
|
134
|
+
}
|
|
135
|
+
function loadMockData(projectDir, screenName) {
|
|
136
|
+
const mockDir = join(projectDir, "mock");
|
|
137
|
+
const candidates = [
|
|
138
|
+
join(mockDir, `${screenName}.yaml`),
|
|
139
|
+
join(mockDir, `${screenName}.yml`),
|
|
140
|
+
join(mockDir, `${screenName}.json`),
|
|
141
|
+
];
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (existsSync(candidate)) {
|
|
144
|
+
const content = readFileSync(candidate, "utf-8");
|
|
145
|
+
let parsed;
|
|
146
|
+
if (candidate.endsWith(".json")) {
|
|
147
|
+
parsed = JSON.parse(content);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
parsed = YAML.parse(content);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
data: parsed?.data ?? parsed ?? {},
|
|
154
|
+
params: parsed?.params ?? {},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { data: {}, params: {} };
|
|
159
|
+
}
|
|
160
|
+
// ── main preview function ───────────────────────────────────────────
|
|
161
|
+
export async function renderPreview(projectCwd, options) {
|
|
162
|
+
const { screen, size_class = "compact", theme = "light", locale: localeName = "en", viewport, include_html = false, } = options;
|
|
163
|
+
// 1. Find project directory
|
|
164
|
+
const projectDir = findProjectDir(projectCwd);
|
|
165
|
+
// 2. Load specs
|
|
166
|
+
const manifest = loadManifest(projectDir);
|
|
167
|
+
const screenSpec = loadScreen(projectDir, manifest, screen);
|
|
168
|
+
const tokens = loadAllTokens(projectDir, manifest);
|
|
169
|
+
const locale = loadLocale(projectDir, manifest, localeName);
|
|
170
|
+
// 3. Load contract definitions (project extensions) and component definitions
|
|
171
|
+
const contractDefs = loadContractDefs(projectDir, manifest);
|
|
172
|
+
manifest._contractDefs = contractDefs;
|
|
173
|
+
const componentDefs = loadComponentDefs(projectDir, manifest);
|
|
174
|
+
manifest._componentDefs = componentDefs;
|
|
175
|
+
// 4. Load mock data
|
|
176
|
+
const { data: mockData, params: mockParams } = loadMockData(projectDir, screen);
|
|
177
|
+
// 5. Build render context
|
|
178
|
+
const ctx = {
|
|
179
|
+
manifest,
|
|
180
|
+
screen: screenSpec,
|
|
181
|
+
screenName: screen,
|
|
182
|
+
tokens,
|
|
183
|
+
locale,
|
|
184
|
+
mockData,
|
|
185
|
+
mockParams,
|
|
186
|
+
sizeClass: size_class,
|
|
187
|
+
theme,
|
|
188
|
+
};
|
|
189
|
+
// 6. Render HTML
|
|
190
|
+
const html = renderPage(ctx);
|
|
191
|
+
// 7. Screenshot with Puppeteer
|
|
192
|
+
const vp = viewport ?? SIZE_CLASS_VIEWPORTS[size_class];
|
|
193
|
+
const browser = await getBrowser();
|
|
194
|
+
const page = await browser.newPage();
|
|
195
|
+
try {
|
|
196
|
+
await page.setViewport({
|
|
197
|
+
width: vp.width,
|
|
198
|
+
height: vp.height,
|
|
199
|
+
deviceScaleFactor: 2,
|
|
200
|
+
});
|
|
201
|
+
if (theme === "dark") {
|
|
202
|
+
await page.emulateMediaFeatures([
|
|
203
|
+
{ name: "prefers-color-scheme", value: "dark" },
|
|
204
|
+
]);
|
|
205
|
+
}
|
|
206
|
+
await page.setContent(html, { waitUntil: "load", timeout: 10_000 });
|
|
207
|
+
// Small delay for CSS to settle
|
|
208
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
209
|
+
const buffer = await page.screenshot({ type: "png", fullPage: true });
|
|
210
|
+
const base64 = buffer.toString("base64");
|
|
211
|
+
const content = [
|
|
212
|
+
{ type: "image", data: base64, mimeType: "image/png" },
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: JSON.stringify({
|
|
216
|
+
screen,
|
|
217
|
+
size_class,
|
|
218
|
+
theme,
|
|
219
|
+
locale: localeName,
|
|
220
|
+
viewport: vp,
|
|
221
|
+
mock_data_loaded: Object.keys(mockData).length > 0,
|
|
222
|
+
}, null, 2),
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
if (include_html) {
|
|
226
|
+
content.push({ type: "text", text: html });
|
|
227
|
+
}
|
|
228
|
+
return { content };
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await page.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android screenshot tool — builds the app, installs on an emulator,
|
|
3
|
+
* and captures real screenshots via adb screencap.
|
|
4
|
+
*
|
|
5
|
+
* Gives pixel-perfect results with real navigation, images, themes,
|
|
6
|
+
* and all runtime behavior. Requires a running Android emulator.
|
|
7
|
+
*/
|
|
8
|
+
import { exec as execCb, execSync } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync, copyFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { findPlatformAppDir, buildScreenshotResponse, } from "./screenshot-shared.js";
|
|
13
|
+
const exec = promisify(execCb);
|
|
14
|
+
const androidScreenshotQueues = new Map();
|
|
15
|
+
// ── constants ───────────────────────────────────────────────────────
|
|
16
|
+
const ADB_SCREENSHOT_PATH = "/sdcard/openuispec_screenshot.png";
|
|
17
|
+
// ── Android app directory discovery ─────────────────────────────────
|
|
18
|
+
function isAndroidProject(dir) {
|
|
19
|
+
return existsSync(join(dir, "gradlew")) ||
|
|
20
|
+
existsSync(join(dir, "app", "build.gradle.kts")) ||
|
|
21
|
+
existsSync(join(dir, "app", "build.gradle"));
|
|
22
|
+
}
|
|
23
|
+
export function findAndroidAppDir(projectCwd, directDir) {
|
|
24
|
+
return findPlatformAppDir(projectCwd, "android", isAndroidProject, directDir);
|
|
25
|
+
}
|
|
26
|
+
// ── app module auto-detection ────────────────────────────────────────
|
|
27
|
+
export function detectAppModule(androidDir) {
|
|
28
|
+
// Read settings.gradle.kts or settings.gradle to find included modules
|
|
29
|
+
for (const settingsFile of ["settings.gradle.kts", "settings.gradle"]) {
|
|
30
|
+
const settingsPath = join(androidDir, settingsFile);
|
|
31
|
+
if (!existsSync(settingsPath))
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const content = readFileSync(settingsPath, "utf-8");
|
|
35
|
+
// Match include(":app"), include(":module1", ":module2"), include ":app"
|
|
36
|
+
const modules = [];
|
|
37
|
+
const includeMatches = content.matchAll(/include\s*\(?([^)\n]+)\)?/g);
|
|
38
|
+
for (const m of includeMatches) {
|
|
39
|
+
const args = m[1];
|
|
40
|
+
const moduleNames = args.matchAll(/[":]+([^",:)\s]+)/g);
|
|
41
|
+
for (const mn of moduleNames) {
|
|
42
|
+
modules.push(mn[1]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// For each module, check if its build.gradle.kts/.gradle has com.android.application
|
|
46
|
+
for (const mod of modules) {
|
|
47
|
+
const modDir = join(androidDir, mod);
|
|
48
|
+
for (const buildFile of ["build.gradle.kts", "build.gradle"]) {
|
|
49
|
+
const buildPath = join(modDir, buildFile);
|
|
50
|
+
if (!existsSync(buildPath))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const buildContent = readFileSync(buildPath, "utf-8");
|
|
54
|
+
if (buildContent.includes("com.android.application")) {
|
|
55
|
+
return mod;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
return "app"; // fallback
|
|
65
|
+
}
|
|
66
|
+
function readBuildFile(androidDir, moduleName) {
|
|
67
|
+
for (const filename of ["build.gradle.kts", "build.gradle"]) {
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(join(androidDir, moduleName, filename), "utf-8");
|
|
70
|
+
}
|
|
71
|
+
catch { /* skip */ }
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
export function extractAppInfo(androidDir, moduleOverride) {
|
|
76
|
+
const moduleName = moduleOverride ?? detectAppModule(androidDir);
|
|
77
|
+
// Try to get applicationId from build.gradle.kts or build.gradle
|
|
78
|
+
let applicationId = "";
|
|
79
|
+
const buildContent = readBuildFile(androidDir, moduleName);
|
|
80
|
+
if (buildContent) {
|
|
81
|
+
// Kotlin DSL: applicationId = "..."
|
|
82
|
+
const appIdMatch = buildContent.match(/applicationId\s*=\s*"([^"]+)"/);
|
|
83
|
+
if (appIdMatch)
|
|
84
|
+
applicationId = appIdMatch[1];
|
|
85
|
+
if (!applicationId) {
|
|
86
|
+
// Groovy DSL: applicationId "..." or applicationId '...'
|
|
87
|
+
const groovyMatch = buildContent.match(/applicationId\s+['"]([^'"]+)['"]/);
|
|
88
|
+
if (groovyMatch)
|
|
89
|
+
applicationId = groovyMatch[1];
|
|
90
|
+
}
|
|
91
|
+
if (!applicationId) {
|
|
92
|
+
const nsMatch = buildContent.match(/namespace\s*=?\s*['"]([^'"]+)['"]/);
|
|
93
|
+
if (nsMatch)
|
|
94
|
+
applicationId = nsMatch[1];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Get launch activity from AndroidManifest.xml
|
|
98
|
+
let launchActivity = ".MainActivity";
|
|
99
|
+
const manifestPath = join(androidDir, moduleName, "src", "main", "AndroidManifest.xml");
|
|
100
|
+
try {
|
|
101
|
+
const manifest = readFileSync(manifestPath, "utf-8");
|
|
102
|
+
// Find activity with MAIN/LAUNCHER intent filter
|
|
103
|
+
const activityMatch = manifest.match(/<activity[^>]*android:name="([^"]+)"[^]*?action android:name="android\.intent\.action\.MAIN"/);
|
|
104
|
+
if (activityMatch)
|
|
105
|
+
launchActivity = activityMatch[1];
|
|
106
|
+
// If applicationId still empty, try manifest package
|
|
107
|
+
if (!applicationId) {
|
|
108
|
+
const pkgMatch = manifest.match(/package="([^"]+)"/);
|
|
109
|
+
if (pkgMatch)
|
|
110
|
+
applicationId = pkgMatch[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch { /* use defaults */ }
|
|
114
|
+
if (!applicationId) {
|
|
115
|
+
throw new Error(`Could not determine applicationId from ${moduleName}/build.gradle.kts (or .gradle) or AndroidManifest.xml`);
|
|
116
|
+
}
|
|
117
|
+
// Resolve relative activity name
|
|
118
|
+
const fullActivity = launchActivity.startsWith(".")
|
|
119
|
+
? `${applicationId}${launchActivity}`
|
|
120
|
+
: launchActivity;
|
|
121
|
+
return { applicationId, launchActivity: fullActivity, moduleName };
|
|
122
|
+
}
|
|
123
|
+
// ── adb helpers ─────────────────────────────────────────────────────
|
|
124
|
+
export function findAdb() {
|
|
125
|
+
// Check ANDROID_HOME first
|
|
126
|
+
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
|
127
|
+
if (androidHome) {
|
|
128
|
+
const adbPath = join(androidHome, "platform-tools", "adb");
|
|
129
|
+
if (existsSync(adbPath))
|
|
130
|
+
return adbPath;
|
|
131
|
+
}
|
|
132
|
+
// Fall back to PATH
|
|
133
|
+
try {
|
|
134
|
+
execSync("which adb", { stdio: "pipe" });
|
|
135
|
+
return "adb";
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error("adb not found. Set ANDROID_HOME or add platform-tools to PATH.\n" +
|
|
139
|
+
"Install via Android Studio or: sdkmanager 'platform-tools'");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function getConnectedEmulator(adb) {
|
|
143
|
+
try {
|
|
144
|
+
const { stdout } = await exec(`${adb} devices`);
|
|
145
|
+
const lines = stdout.trim().split("\n").slice(1); // skip header
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
const [serial, state] = line.split("\t");
|
|
148
|
+
if (state === "device" && (serial.startsWith("emulator-") || serial.includes(":"))) {
|
|
149
|
+
return serial;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Also accept physical devices if no emulator
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
const [serial, state] = line.split("\t");
|
|
155
|
+
if (state === "device")
|
|
156
|
+
return serial;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* fall through */ }
|
|
160
|
+
throw new Error("No connected Android device or emulator found.\n" +
|
|
161
|
+
"Start an emulator from Android Studio or run: emulator -avd <avd_name>");
|
|
162
|
+
}
|
|
163
|
+
export async function adbShell(adb, serial, cmd) {
|
|
164
|
+
const { stdout } = await exec(`${adb} -s ${serial} shell ${cmd}`, { timeout: 30_000 });
|
|
165
|
+
return stdout.trim();
|
|
166
|
+
}
|
|
167
|
+
export async function adbExec(adb, serial, args) {
|
|
168
|
+
const { stdout } = await exec(`${adb} -s ${serial} ${args}`, { timeout: 60_000 });
|
|
169
|
+
return stdout.trim();
|
|
170
|
+
}
|
|
171
|
+
// ── emulator storage cleanup ─────────────────────────────────────────
|
|
172
|
+
export async function cleanEmulatorStorage(adb, serial) {
|
|
173
|
+
const cmds = [
|
|
174
|
+
`pm trim-caches 2G`, // aggressively trim package cache
|
|
175
|
+
`rm -rf /data/local/tmp/*.apk`, // leftover APKs from previous installs
|
|
176
|
+
`rm -f /sdcard/openuispec_screenshot.png /sdcard/ui_dump.xml /sdcard/screenshot.png`,
|
|
177
|
+
];
|
|
178
|
+
for (const cmd of cmds) {
|
|
179
|
+
try {
|
|
180
|
+
await adbShell(adb, serial, cmd);
|
|
181
|
+
}
|
|
182
|
+
catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ── build APK ───────────────────────────────────────────────────────
|
|
186
|
+
export async function buildApk(androidDir, moduleName) {
|
|
187
|
+
if (!existsSync(join(androidDir, "gradlew"))) {
|
|
188
|
+
throw new Error(`No gradlew found in ${androidDir}. Initialize the Gradle wrapper first.`);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await exec(`./gradlew :${moduleName}:assembleDebug`, {
|
|
192
|
+
cwd: androidDir,
|
|
193
|
+
timeout: 300_000,
|
|
194
|
+
env: { ...process.env, JAVA_OPTS: "-Xmx2g" },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).slice(-500);
|
|
199
|
+
throw new Error(`APK build failed:\n${output}`);
|
|
200
|
+
}
|
|
201
|
+
// Find the built APK
|
|
202
|
+
const apkCandidates = [
|
|
203
|
+
join(androidDir, moduleName, "build", "outputs", "apk", "debug", `${moduleName}-debug.apk`),
|
|
204
|
+
join(androidDir, moduleName, "build", "outputs", "apk", "debug", `${moduleName}-debug-unsigned.apk`),
|
|
205
|
+
// Common fallback names
|
|
206
|
+
join(androidDir, moduleName, "build", "outputs", "apk", "debug", "app-debug.apk"),
|
|
207
|
+
join(androidDir, moduleName, "build", "outputs", "apk", "debug", "app-debug-unsigned.apk"),
|
|
208
|
+
];
|
|
209
|
+
for (const apk of apkCandidates) {
|
|
210
|
+
if (existsSync(apk))
|
|
211
|
+
return apk;
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`APK not found after build in ${moduleName}/build/outputs/. Check Gradle output.`);
|
|
214
|
+
}
|
|
215
|
+
// ── install & launch ────────────────────────────────────────────────
|
|
216
|
+
export async function installAndLaunch(adb, serial, apkPath, appInfo, route) {
|
|
217
|
+
// Force-stop and uninstall to free storage + wipe saved nav state
|
|
218
|
+
await adbShell(adb, serial, `am force-stop ${appInfo.applicationId}`);
|
|
219
|
+
try {
|
|
220
|
+
await adbShell(adb, serial, `pm uninstall ${appInfo.applicationId}`);
|
|
221
|
+
}
|
|
222
|
+
catch { /* not installed */ }
|
|
223
|
+
// Install fresh (not -r replace, since we uninstalled)
|
|
224
|
+
await adbExec(adb, serial, `install "${apkPath}"`);
|
|
225
|
+
if (route) {
|
|
226
|
+
await adbShell(adb, serial, `am start -W -a android.intent.action.VIEW -d '${route}' ` +
|
|
227
|
+
`${appInfo.applicationId}/${appInfo.launchActivity}`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await adbShell(adb, serial, `am start -W -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export async function launchInstalledApp(adb, serial, appInfo, route) {
|
|
234
|
+
await adbShell(adb, serial, `am force-stop ${appInfo.applicationId}`);
|
|
235
|
+
// Clear saved nav state so deep links route correctly
|
|
236
|
+
try {
|
|
237
|
+
await adbShell(adb, serial, `pm clear ${appInfo.applicationId}`);
|
|
238
|
+
}
|
|
239
|
+
catch { /* ignore */ }
|
|
240
|
+
if (route) {
|
|
241
|
+
await adbShell(adb, serial, `am start -W -a android.intent.action.VIEW -d '${route}' ` +
|
|
242
|
+
`${appInfo.applicationId}/${appInfo.launchActivity}`);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
await adbShell(adb, serial, `am start -W -n ${appInfo.applicationId}/${appInfo.launchActivity}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ── theme control ───────────────────────────────────────────────────
|
|
249
|
+
export async function setTheme(adb, serial, theme) {
|
|
250
|
+
const mode = theme === "dark" ? "yes" : "no";
|
|
251
|
+
await adbShell(adb, serial, `cmd uimode night ${mode}`);
|
|
252
|
+
}
|
|
253
|
+
// ── UI navigation via tap ───────────────────────────────────────────
|
|
254
|
+
export async function tapByText(adb, serial, text) {
|
|
255
|
+
// Dump UI hierarchy
|
|
256
|
+
await adbShell(adb, serial, `uiautomator dump /sdcard/ui_dump.xml`);
|
|
257
|
+
const xml = await adbShell(adb, serial, `cat /sdcard/ui_dump.xml`);
|
|
258
|
+
await adbShell(adb, serial, `rm /sdcard/ui_dump.xml`);
|
|
259
|
+
const lowerText = text.toLowerCase();
|
|
260
|
+
// Parse each <node .../> extracting text, content-desc, and bounds
|
|
261
|
+
const nodeRegex = /<node\s[^>]+>/g;
|
|
262
|
+
const nodes = [];
|
|
263
|
+
let nodeMatch;
|
|
264
|
+
while ((nodeMatch = nodeRegex.exec(xml)) !== null) {
|
|
265
|
+
const attrs = nodeMatch[0];
|
|
266
|
+
const textMatch = attrs.match(/\btext="([^"]*)"/);
|
|
267
|
+
const descMatch = attrs.match(/\bcontent-desc="([^"]*)"/);
|
|
268
|
+
const boundsMatch = attrs.match(/\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
|
269
|
+
if (!boundsMatch)
|
|
270
|
+
continue;
|
|
271
|
+
nodes.push({
|
|
272
|
+
text: textMatch?.[1] ?? "",
|
|
273
|
+
desc: descMatch?.[1] ?? "",
|
|
274
|
+
cx: Math.round((parseInt(boundsMatch[1]) + parseInt(boundsMatch[3])) / 2),
|
|
275
|
+
cy: Math.round((parseInt(boundsMatch[2]) + parseInt(boundsMatch[4])) / 2),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Exact match on text or content-desc
|
|
279
|
+
for (const node of nodes) {
|
|
280
|
+
if (node.text.toLowerCase() === lowerText || node.desc.toLowerCase() === lowerText) {
|
|
281
|
+
await adbShell(adb, serial, `input tap ${node.cx} ${node.cy}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Partial/contains match
|
|
286
|
+
for (const node of nodes) {
|
|
287
|
+
if (node.text.toLowerCase().includes(lowerText) || node.desc.toLowerCase().includes(lowerText)) {
|
|
288
|
+
await adbShell(adb, serial, `input tap ${node.cx} ${node.cy}`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
throw new Error(`UI element with text "${text}" not found on screen.`);
|
|
293
|
+
}
|
|
294
|
+
export async function navigateByTaps(adb, serial, steps) {
|
|
295
|
+
for (const step of steps) {
|
|
296
|
+
await tapByText(adb, serial, step);
|
|
297
|
+
// Wait for navigation animation
|
|
298
|
+
await new Promise(r => setTimeout(r, 800));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ── screenshot capture ──────────────────────────────────────────────
|
|
302
|
+
export async function captureScreenshot(adb, serial, localPath) {
|
|
303
|
+
try {
|
|
304
|
+
await exec(`${adb} -s ${serial} exec-out screencap -p > "${localPath}"`, { timeout: 60_000, shell: "/bin/bash" });
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
const output = ((err.stderr ?? "") + "\n" + (err.stdout ?? "")).trim();
|
|
308
|
+
throw new Error(`Android screenshot capture failed${output ? `:\n${output}` : "."}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// ── wait for app ready ──────────────────────────────────────────────
|
|
312
|
+
async function waitForAppReady(adb, serial, applicationId, waitMs) {
|
|
313
|
+
// Wait for the activity to be in resumed state
|
|
314
|
+
const startTime = Date.now();
|
|
315
|
+
const timeout = Math.min(waitMs, 15_000);
|
|
316
|
+
while (Date.now() - startTime < timeout) {
|
|
317
|
+
try {
|
|
318
|
+
const output = await adbShell(adb, serial, `dumpsys activity activities | grep -E "mResumedActivity|topResumedActivity"`);
|
|
319
|
+
if (output.includes(applicationId)) {
|
|
320
|
+
// App is in foreground, wait the remaining time for content to load
|
|
321
|
+
const elapsed = Date.now() - startTime;
|
|
322
|
+
const remaining = Math.max(waitMs - elapsed, 500);
|
|
323
|
+
await new Promise(r => setTimeout(r, remaining));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch { /* activity not ready yet */ }
|
|
328
|
+
await new Promise(r => setTimeout(r, 500));
|
|
329
|
+
}
|
|
330
|
+
// Fallback: just wait the full duration
|
|
331
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
332
|
+
}
|
|
333
|
+
async function takeSingleAndroidCapture(adb, serial, androidDir, appInfo, capture, theme, defaultOutputDir) {
|
|
334
|
+
await launchInstalledApp(adb, serial, appInfo, capture.route);
|
|
335
|
+
await waitForAppReady(adb, serial, appInfo.applicationId, capture.wait_for ?? 3000);
|
|
336
|
+
if (capture.nav && capture.nav.length > 0) {
|
|
337
|
+
await navigateByTaps(adb, serial, capture.nav);
|
|
338
|
+
}
|
|
339
|
+
const themeLabel = theme ?? "default";
|
|
340
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
341
|
+
const tmpPath = join(androidDir, `.openuispec-screenshot-${capture.screen}.png`);
|
|
342
|
+
await captureScreenshot(adb, serial, tmpPath);
|
|
343
|
+
let savedPath = filename;
|
|
344
|
+
if (defaultOutputDir) {
|
|
345
|
+
const outDir = resolve(androidDir, defaultOutputDir);
|
|
346
|
+
mkdirSync(outDir, { recursive: true });
|
|
347
|
+
savedPath = join(outDir, filename);
|
|
348
|
+
copyFileSync(tmpPath, savedPath);
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
const data = readFileSync(tmpPath).toString("base64");
|
|
352
|
+
return {
|
|
353
|
+
screen: capture.screen,
|
|
354
|
+
path: savedPath,
|
|
355
|
+
data,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
try {
|
|
360
|
+
unlinkSync(tmpPath);
|
|
361
|
+
}
|
|
362
|
+
catch { /* ignore */ }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// ── main entry point ────────────────────────────────────────────────
|
|
366
|
+
export async function takeAndroidScreenshot(projectCwd, options) {
|
|
367
|
+
const { screen, route, nav, theme, wait_for = 3000, output_dir, project_dir, module, } = options;
|
|
368
|
+
// 1. Find Android project
|
|
369
|
+
const androidDir = findAndroidAppDir(projectCwd, project_dir);
|
|
370
|
+
const appInfo = extractAppInfo(androidDir, module);
|
|
371
|
+
// 2. Find adb and emulator
|
|
372
|
+
const adb = findAdb();
|
|
373
|
+
const serial = await getConnectedEmulator(adb);
|
|
374
|
+
const previousRun = androidScreenshotQueues.get(serial) ?? Promise.resolve();
|
|
375
|
+
let releaseQueue;
|
|
376
|
+
const currentRun = new Promise((resolve) => {
|
|
377
|
+
releaseQueue = resolve;
|
|
378
|
+
});
|
|
379
|
+
const queuedRun = previousRun.then(() => currentRun);
|
|
380
|
+
androidScreenshotQueues.set(serial, queuedRun);
|
|
381
|
+
await previousRun;
|
|
382
|
+
try {
|
|
383
|
+
// 3. Free emulator storage before build/install
|
|
384
|
+
await cleanEmulatorStorage(adb, serial);
|
|
385
|
+
// 4. Build APK
|
|
386
|
+
const apkPath = await buildApk(androidDir, appInfo.moduleName);
|
|
387
|
+
// 5. Set theme if requested
|
|
388
|
+
if (theme) {
|
|
389
|
+
await setTheme(adb, serial, theme);
|
|
390
|
+
}
|
|
391
|
+
// 6. Install fresh once, then capture
|
|
392
|
+
await installAndLaunch(adb, serial, apkPath, appInfo, route);
|
|
393
|
+
const snapshot = await takeSingleAndroidCapture(adb, serial, androidDir, appInfo, { screen: screen ?? "main", route, nav, wait_for }, theme, output_dir);
|
|
394
|
+
return buildScreenshotResponse([snapshot], (s) => ({
|
|
395
|
+
screen: s.screen,
|
|
396
|
+
path: snapshot.path ?? null,
|
|
397
|
+
emulator: serial,
|
|
398
|
+
theme: theme ?? "default",
|
|
399
|
+
applicationId: appInfo.applicationId,
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
releaseQueue?.();
|
|
404
|
+
if (androidScreenshotQueues.get(serial) === queuedRun) {
|
|
405
|
+
androidScreenshotQueues.delete(serial);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
export async function takeAndroidScreenshotBatch(projectCwd, options) {
|
|
410
|
+
const { captures, theme, output_dir, project_dir, module } = options;
|
|
411
|
+
if (captures.length === 0) {
|
|
412
|
+
return {
|
|
413
|
+
content: [{ type: "text", text: "No Android captures specified." }],
|
|
414
|
+
isError: true,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const androidDir = findAndroidAppDir(projectCwd, project_dir);
|
|
418
|
+
const appInfo = extractAppInfo(androidDir, module);
|
|
419
|
+
const adb = findAdb();
|
|
420
|
+
const serial = await getConnectedEmulator(adb);
|
|
421
|
+
const previousRun = androidScreenshotQueues.get(serial) ?? Promise.resolve();
|
|
422
|
+
let releaseQueue;
|
|
423
|
+
const currentRun = new Promise((resolve) => {
|
|
424
|
+
releaseQueue = resolve;
|
|
425
|
+
});
|
|
426
|
+
const queuedRun = previousRun.then(() => currentRun);
|
|
427
|
+
androidScreenshotQueues.set(serial, queuedRun);
|
|
428
|
+
await previousRun;
|
|
429
|
+
try {
|
|
430
|
+
await cleanEmulatorStorage(adb, serial);
|
|
431
|
+
const apkPath = await buildApk(androidDir, appInfo.moduleName);
|
|
432
|
+
if (theme) {
|
|
433
|
+
await setTheme(adb, serial, theme);
|
|
434
|
+
}
|
|
435
|
+
await installAndLaunch(adb, serial, apkPath, appInfo);
|
|
436
|
+
// Pre-create output dir once
|
|
437
|
+
if (output_dir)
|
|
438
|
+
mkdirSync(resolve(androidDir, output_dir), { recursive: true });
|
|
439
|
+
const snapshots = [];
|
|
440
|
+
for (let index = 0; index < captures.length; index += 1) {
|
|
441
|
+
const capture = captures[index];
|
|
442
|
+
snapshots.push(await takeSingleAndroidCapture(adb, serial, androidDir, appInfo, capture, theme, output_dir));
|
|
443
|
+
}
|
|
444
|
+
return buildScreenshotResponse(snapshots, (snapshot) => ({
|
|
445
|
+
screen: snapshot.screen,
|
|
446
|
+
path: snapshot.path,
|
|
447
|
+
emulator: serial,
|
|
448
|
+
theme: theme ?? "default",
|
|
449
|
+
applicationId: appInfo.applicationId,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
releaseQueue?.();
|
|
454
|
+
if (androidScreenshotQueues.get(serial) === queuedRun) {
|
|
455
|
+
androidScreenshotQueues.delete(serial);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|