the-android-mcp 3.2.0 → 3.4.0
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/dist/web-ui.d.ts.map +1 -1
- package/dist/web-ui.js +1248 -274
- package/dist/web-ui.js.map +1 -1
- package/package.json +1 -1
package/dist/web-ui.js
CHANGED
|
@@ -19,9 +19,11 @@ exports.DEFAULT_WEB_UI_HOST = '127.0.0.1';
|
|
|
19
19
|
exports.DEFAULT_WEB_UI_PORT = 50000;
|
|
20
20
|
const UPDATE_HINT = 'npm install -g the-android-mcp@latest';
|
|
21
21
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
22
|
-
const MAX_EVENTS =
|
|
23
|
-
const
|
|
24
|
-
const
|
|
22
|
+
const MAX_EVENTS = 600;
|
|
23
|
+
const MAX_JOBS = 300;
|
|
24
|
+
const APP_STATE_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
|
|
25
|
+
const WORKFLOW_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-workflows.json');
|
|
26
|
+
const SESSION_EVENTS_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-session-events.ndjson');
|
|
25
27
|
const SNAPSHOT_KINDS = [
|
|
26
28
|
'radio',
|
|
27
29
|
'display',
|
|
@@ -31,17 +33,41 @@ const SNAPSHOT_KINDS = [
|
|
|
31
33
|
];
|
|
32
34
|
const serverStartedAt = Date.now();
|
|
33
35
|
let eventSeq = 1;
|
|
36
|
+
let jobSeq = 1;
|
|
34
37
|
const eventHistory = [];
|
|
35
38
|
const sseClients = new Set();
|
|
36
39
|
const metrics = {};
|
|
37
40
|
const snapshotCache = {};
|
|
38
41
|
let workflows = loadWorkflows();
|
|
42
|
+
const jobs = [];
|
|
43
|
+
const lanes = {};
|
|
39
44
|
function nowIso() {
|
|
40
45
|
return new Date().toISOString();
|
|
41
46
|
}
|
|
47
|
+
function ensureAppStateDir() {
|
|
48
|
+
if (!fs_1.default.existsSync(APP_STATE_DIR)) {
|
|
49
|
+
fs_1.default.mkdirSync(APP_STATE_DIR, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function appendSessionEvent(event) {
|
|
53
|
+
try {
|
|
54
|
+
ensureAppStateDir();
|
|
55
|
+
fs_1.default.appendFileSync(SESSION_EVENTS_FILE, `${JSON.stringify(event)}\n`, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// ignore event persistence failures
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
function isSnapshotKind(value) {
|
|
43
62
|
return SNAPSHOT_KINDS.includes(value);
|
|
44
63
|
}
|
|
64
|
+
function isJobType(value) {
|
|
65
|
+
return (value === 'open_url' ||
|
|
66
|
+
value === 'snapshot_suite' ||
|
|
67
|
+
value === 'stress_run' ||
|
|
68
|
+
value === 'workflow_run' ||
|
|
69
|
+
value === 'device_profile');
|
|
70
|
+
}
|
|
45
71
|
function clampInt(value, fallback, min, max) {
|
|
46
72
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
47
73
|
return fallback;
|
|
@@ -49,41 +75,6 @@ function clampInt(value, fallback, min, max) {
|
|
|
49
75
|
const parsed = Math.trunc(value);
|
|
50
76
|
return Math.max(min, Math.min(max, parsed));
|
|
51
77
|
}
|
|
52
|
-
function trackMetric(name, durationMs, ok, errorMessage) {
|
|
53
|
-
const entry = metrics[name] ?? {
|
|
54
|
-
name,
|
|
55
|
-
count: 0,
|
|
56
|
-
success: 0,
|
|
57
|
-
errors: 0,
|
|
58
|
-
totalDurationMs: 0,
|
|
59
|
-
lastDurationMs: 0,
|
|
60
|
-
};
|
|
61
|
-
entry.count += 1;
|
|
62
|
-
entry.totalDurationMs += durationMs;
|
|
63
|
-
entry.lastDurationMs = durationMs;
|
|
64
|
-
entry.lastAt = nowIso();
|
|
65
|
-
if (ok) {
|
|
66
|
-
entry.success += 1;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
entry.errors += 1;
|
|
70
|
-
entry.lastError = errorMessage;
|
|
71
|
-
}
|
|
72
|
-
metrics[name] = entry;
|
|
73
|
-
}
|
|
74
|
-
async function withMetric(name, fn) {
|
|
75
|
-
const startedAt = Date.now();
|
|
76
|
-
try {
|
|
77
|
-
const result = await fn();
|
|
78
|
-
trackMetric(name, Date.now() - startedAt, true);
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
catch (error) {
|
|
82
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
-
trackMetric(name, Date.now() - startedAt, false, message);
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
78
|
function pushEvent(type, message, data) {
|
|
88
79
|
const event = {
|
|
89
80
|
id: eventSeq++,
|
|
@@ -96,6 +87,7 @@ function pushEvent(type, message, data) {
|
|
|
96
87
|
if (eventHistory.length > MAX_EVENTS) {
|
|
97
88
|
eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
|
|
98
89
|
}
|
|
90
|
+
appendSessionEvent(event);
|
|
99
91
|
broadcastEvent(event);
|
|
100
92
|
return event;
|
|
101
93
|
}
|
|
@@ -126,6 +118,41 @@ function sendHtml(response, html) {
|
|
|
126
118
|
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
127
119
|
response.end(html);
|
|
128
120
|
}
|
|
121
|
+
function trackMetric(name, durationMs, ok, errorMessage) {
|
|
122
|
+
const entry = metrics[name] ?? {
|
|
123
|
+
name,
|
|
124
|
+
count: 0,
|
|
125
|
+
success: 0,
|
|
126
|
+
errors: 0,
|
|
127
|
+
totalDurationMs: 0,
|
|
128
|
+
lastDurationMs: 0,
|
|
129
|
+
};
|
|
130
|
+
entry.count += 1;
|
|
131
|
+
entry.totalDurationMs += durationMs;
|
|
132
|
+
entry.lastDurationMs = durationMs;
|
|
133
|
+
entry.lastAt = nowIso();
|
|
134
|
+
if (ok) {
|
|
135
|
+
entry.success += 1;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
entry.errors += 1;
|
|
139
|
+
entry.lastError = errorMessage;
|
|
140
|
+
}
|
|
141
|
+
metrics[name] = entry;
|
|
142
|
+
}
|
|
143
|
+
async function withMetric(name, fn) {
|
|
144
|
+
const startedAt = Date.now();
|
|
145
|
+
try {
|
|
146
|
+
const result = await fn();
|
|
147
|
+
trackMetric(name, Date.now() - startedAt, true);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
trackMetric(name, Date.now() - startedAt, false, message);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
129
156
|
function compactStringSummary(value) {
|
|
130
157
|
if (typeof value !== 'string') {
|
|
131
158
|
return { type: typeof value };
|
|
@@ -133,7 +160,7 @@ function compactStringSummary(value) {
|
|
|
133
160
|
return {
|
|
134
161
|
length: value.length,
|
|
135
162
|
lines: value.split('\n').length,
|
|
136
|
-
preview: value.slice(0,
|
|
163
|
+
preview: value.slice(0, 220),
|
|
137
164
|
};
|
|
138
165
|
}
|
|
139
166
|
function summarizeObjectShape(input) {
|
|
@@ -157,7 +184,7 @@ function summarizeObjectShape(input) {
|
|
|
157
184
|
result[key] = { type: 'array', length: value.length };
|
|
158
185
|
continue;
|
|
159
186
|
}
|
|
160
|
-
if (typeof value === 'object'
|
|
187
|
+
if (typeof value === 'object') {
|
|
161
188
|
result[key] = { type: 'object', keys: Object.keys(value).length };
|
|
162
189
|
continue;
|
|
163
190
|
}
|
|
@@ -165,27 +192,49 @@ function summarizeObjectShape(input) {
|
|
|
165
192
|
}
|
|
166
193
|
return result;
|
|
167
194
|
}
|
|
195
|
+
function defaultWorkflows() {
|
|
196
|
+
const createdAt = nowIso();
|
|
197
|
+
return {
|
|
198
|
+
'smoke-web-flow': {
|
|
199
|
+
name: 'smoke-web-flow',
|
|
200
|
+
description: 'Open URLs and collect lightweight snapshots.',
|
|
201
|
+
updatedAt: createdAt,
|
|
202
|
+
steps: [
|
|
203
|
+
{ type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 900 },
|
|
204
|
+
{ type: 'snapshot', snapshot: 'radio' },
|
|
205
|
+
{ type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 900 },
|
|
206
|
+
{ type: 'snapshot', snapshot: 'display' },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
'diagnostic-suite': {
|
|
210
|
+
name: 'diagnostic-suite',
|
|
211
|
+
description: 'Run complete v3 snapshot suite.',
|
|
212
|
+
updatedAt: createdAt,
|
|
213
|
+
steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
168
217
|
function normalizeWorkflowStep(value) {
|
|
169
218
|
if (!value || typeof value !== 'object') {
|
|
170
|
-
throw new Error('
|
|
219
|
+
throw new Error('Workflow step must be an object');
|
|
171
220
|
}
|
|
172
221
|
const step = value;
|
|
173
222
|
const type = step.type;
|
|
174
223
|
if (type !== 'open_url' && type !== 'snapshot' && type !== 'snapshot_suite' && type !== 'sleep_ms') {
|
|
175
|
-
throw new Error('
|
|
224
|
+
throw new Error('Unsupported workflow step type');
|
|
176
225
|
}
|
|
177
226
|
const normalized = { type };
|
|
178
227
|
if (type === 'open_url') {
|
|
179
228
|
if (typeof step.url !== 'string' || !/^https?:\/\//i.test(step.url)) {
|
|
180
|
-
throw new Error('
|
|
229
|
+
throw new Error('open_url step requires valid url');
|
|
181
230
|
}
|
|
182
231
|
normalized.url = step.url;
|
|
183
|
-
normalized.waitForReadyMs = clampInt(step.waitForReadyMs,
|
|
232
|
+
normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
|
|
184
233
|
return normalized;
|
|
185
234
|
}
|
|
186
235
|
if (type === 'snapshot') {
|
|
187
236
|
if (typeof step.snapshot !== 'string' || !isSnapshotKind(step.snapshot)) {
|
|
188
|
-
throw new Error('
|
|
237
|
+
throw new Error('snapshot step requires valid snapshot kind');
|
|
189
238
|
}
|
|
190
239
|
normalized.snapshot = step.snapshot;
|
|
191
240
|
if (typeof step.packageName === 'string') {
|
|
@@ -200,42 +249,12 @@ function normalizeWorkflowStep(value) {
|
|
|
200
249
|
}
|
|
201
250
|
return normalized;
|
|
202
251
|
}
|
|
203
|
-
normalized.durationMs = clampInt(step.durationMs, 500, 50,
|
|
252
|
+
normalized.durationMs = clampInt(step.durationMs, 500, 50, 30000);
|
|
204
253
|
return normalized;
|
|
205
254
|
}
|
|
206
|
-
function ensureWorkflowStore() {
|
|
207
|
-
if (!fs_1.default.existsSync(WORKFLOW_DIR)) {
|
|
208
|
-
fs_1.default.mkdirSync(WORKFLOW_DIR, { recursive: true });
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
function defaultWorkflows() {
|
|
212
|
-
const createdAt = nowIso();
|
|
213
|
-
const smoke = {
|
|
214
|
-
name: 'smoke-web-flow',
|
|
215
|
-
description: 'Open three URLs and capture radio/display quickly.',
|
|
216
|
-
updatedAt: createdAt,
|
|
217
|
-
steps: [
|
|
218
|
-
{ type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 1000 },
|
|
219
|
-
{ type: 'snapshot', snapshot: 'radio' },
|
|
220
|
-
{ type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 1000 },
|
|
221
|
-
{ type: 'snapshot', snapshot: 'display' },
|
|
222
|
-
{ type: 'open_url', url: 'https://developer.android.com', waitForReadyMs: 1000 },
|
|
223
|
-
],
|
|
224
|
-
};
|
|
225
|
-
const diagnostic = {
|
|
226
|
-
name: 'diagnostic-suite',
|
|
227
|
-
description: 'Capture full v3 snapshot suite for current device.',
|
|
228
|
-
updatedAt: createdAt,
|
|
229
|
-
steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
|
|
230
|
-
};
|
|
231
|
-
return {
|
|
232
|
-
[smoke.name]: smoke,
|
|
233
|
-
[diagnostic.name]: diagnostic,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
255
|
function loadWorkflows() {
|
|
237
256
|
try {
|
|
238
|
-
|
|
257
|
+
ensureAppStateDir();
|
|
239
258
|
if (!fs_1.default.existsSync(WORKFLOW_FILE)) {
|
|
240
259
|
const defaults = defaultWorkflows();
|
|
241
260
|
fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(defaults, null, 2), 'utf8');
|
|
@@ -243,7 +262,7 @@ function loadWorkflows() {
|
|
|
243
262
|
}
|
|
244
263
|
const raw = fs_1.default.readFileSync(WORKFLOW_FILE, 'utf8');
|
|
245
264
|
const parsed = JSON.parse(raw);
|
|
246
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
265
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
247
266
|
return defaultWorkflows();
|
|
248
267
|
}
|
|
249
268
|
const result = {};
|
|
@@ -255,8 +274,19 @@ function loadWorkflows() {
|
|
|
255
274
|
if (typeof item.name !== 'string' || item.name.trim().length === 0) {
|
|
256
275
|
continue;
|
|
257
276
|
}
|
|
258
|
-
const
|
|
259
|
-
const steps =
|
|
277
|
+
const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
|
|
278
|
+
const steps = [];
|
|
279
|
+
for (const stepRaw of stepsRaw) {
|
|
280
|
+
try {
|
|
281
|
+
steps.push(normalizeWorkflowStep(stepRaw));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// skip invalid step
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (steps.length === 0) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
260
290
|
result[name] = {
|
|
261
291
|
name,
|
|
262
292
|
description: typeof item.description === 'string' ? item.description : undefined,
|
|
@@ -264,29 +294,26 @@ function loadWorkflows() {
|
|
|
264
294
|
steps,
|
|
265
295
|
};
|
|
266
296
|
}
|
|
267
|
-
|
|
268
|
-
return defaultWorkflows();
|
|
269
|
-
}
|
|
270
|
-
return result;
|
|
297
|
+
return Object.keys(result).length > 0 ? result : defaultWorkflows();
|
|
271
298
|
}
|
|
272
299
|
catch {
|
|
273
300
|
return defaultWorkflows();
|
|
274
301
|
}
|
|
275
302
|
}
|
|
276
303
|
function saveWorkflows() {
|
|
277
|
-
|
|
304
|
+
ensureAppStateDir();
|
|
278
305
|
fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(workflows, null, 2), 'utf8');
|
|
279
306
|
}
|
|
280
307
|
function workflowsList() {
|
|
281
308
|
return Object.values(workflows).sort((a, b) => a.name.localeCompare(b.name));
|
|
282
309
|
}
|
|
283
|
-
async function sleepMs(durationMs) {
|
|
284
|
-
await new Promise(resolve => setTimeout(resolve, durationMs));
|
|
285
|
-
}
|
|
286
310
|
function extractDeviceId(body) {
|
|
287
311
|
const value = body.deviceId;
|
|
288
312
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
289
313
|
}
|
|
314
|
+
async function sleepMs(durationMs) {
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, durationMs));
|
|
316
|
+
}
|
|
290
317
|
function captureSnapshot(kind, options) {
|
|
291
318
|
const includeRaw = options.includeRaw === true;
|
|
292
319
|
if (kind === 'radio') {
|
|
@@ -361,7 +388,7 @@ function captureSnapshot(kind, options) {
|
|
|
361
388
|
}
|
|
362
389
|
function diffSnapshot(prev, next) {
|
|
363
390
|
if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') {
|
|
364
|
-
return { changedCount: 0, changed: [], note: 'No comparable snapshots' };
|
|
391
|
+
return { changedCount: 0, changed: [], note: 'No comparable snapshots available' };
|
|
365
392
|
}
|
|
366
393
|
const a = prev;
|
|
367
394
|
const b = next;
|
|
@@ -370,23 +397,16 @@ function diffSnapshot(prev, next) {
|
|
|
370
397
|
for (const key of keys) {
|
|
371
398
|
const oldValue = a[key];
|
|
372
399
|
const newValue = b[key];
|
|
373
|
-
if (typeof oldValue === 'string' || typeof newValue === 'string') {
|
|
374
|
-
const oldText = typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue);
|
|
375
|
-
const newText = typeof newValue === 'string' ? newValue : JSON.stringify(newValue);
|
|
376
|
-
if (oldText !== newText) {
|
|
377
|
-
changed.push({
|
|
378
|
-
key,
|
|
379
|
-
type: 'text',
|
|
380
|
-
beforeLength: oldText.length,
|
|
381
|
-
afterLength: newText.length,
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
continue;
|
|
385
|
-
}
|
|
386
400
|
const oldJson = JSON.stringify(oldValue);
|
|
387
401
|
const newJson = JSON.stringify(newValue);
|
|
388
402
|
if (oldJson !== newJson) {
|
|
389
|
-
changed.push({
|
|
403
|
+
changed.push({
|
|
404
|
+
key,
|
|
405
|
+
beforeType: typeof oldValue,
|
|
406
|
+
afterType: typeof newValue,
|
|
407
|
+
beforeSize: oldJson ? oldJson.length : 0,
|
|
408
|
+
afterSize: newJson ? newJson.length : 0,
|
|
409
|
+
});
|
|
390
410
|
}
|
|
391
411
|
}
|
|
392
412
|
return {
|
|
@@ -413,6 +433,114 @@ function getSnapshotSuite(deviceId, packageName) {
|
|
|
413
433
|
},
|
|
414
434
|
};
|
|
415
435
|
}
|
|
436
|
+
function computeHealthScore(profile) {
|
|
437
|
+
let score = 100;
|
|
438
|
+
const radio = profile.radio;
|
|
439
|
+
const location = profile.location;
|
|
440
|
+
const packages = profile.packages;
|
|
441
|
+
if (radio && radio.airplaneMode === '1') {
|
|
442
|
+
score -= 25;
|
|
443
|
+
}
|
|
444
|
+
if (location && location.mode === '0') {
|
|
445
|
+
score -= 20;
|
|
446
|
+
}
|
|
447
|
+
if (packages && typeof packages.disabled === 'number' && packages.disabled > 20) {
|
|
448
|
+
score -= 10;
|
|
449
|
+
}
|
|
450
|
+
return Math.max(0, Math.min(100, score));
|
|
451
|
+
}
|
|
452
|
+
function buildDeviceProfile(deviceId, packageName) {
|
|
453
|
+
const startedAt = Date.now();
|
|
454
|
+
const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
|
|
455
|
+
deviceId,
|
|
456
|
+
includeWifiDump: false,
|
|
457
|
+
includeTelephonyDump: false,
|
|
458
|
+
includeBluetoothDump: false,
|
|
459
|
+
includeIpState: true,
|
|
460
|
+
includeConnectivityDump: false,
|
|
461
|
+
});
|
|
462
|
+
const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
|
|
463
|
+
deviceId,
|
|
464
|
+
includeDisplayDump: false,
|
|
465
|
+
includeWindowDump: false,
|
|
466
|
+
includeSurfaceFlinger: false,
|
|
467
|
+
});
|
|
468
|
+
const location = (0, adb_js_1.captureAndroidLocationSnapshot)({
|
|
469
|
+
deviceId,
|
|
470
|
+
packageName,
|
|
471
|
+
includeLocationDump: false,
|
|
472
|
+
includeLocationAppOps: true,
|
|
473
|
+
});
|
|
474
|
+
const power = (0, adb_js_1.captureAndroidPowerIdleSnapshot)({
|
|
475
|
+
deviceId,
|
|
476
|
+
includePowerDump: false,
|
|
477
|
+
includeDeviceIdle: false,
|
|
478
|
+
includeBatteryStats: false,
|
|
479
|
+
includeThermal: false,
|
|
480
|
+
});
|
|
481
|
+
const packages = (0, adb_js_1.captureAndroidPackageInventorySnapshot)({
|
|
482
|
+
deviceId,
|
|
483
|
+
includeThirdParty: true,
|
|
484
|
+
includeSystem: true,
|
|
485
|
+
includeDisabled: true,
|
|
486
|
+
includePackagePaths: false,
|
|
487
|
+
includeFeatures: false,
|
|
488
|
+
packageListLines: 600,
|
|
489
|
+
});
|
|
490
|
+
const profile = {
|
|
491
|
+
capturedAt: nowIso(),
|
|
492
|
+
durationMs: Date.now() - startedAt,
|
|
493
|
+
deviceId: radio.deviceId,
|
|
494
|
+
radio: {
|
|
495
|
+
wifiEnabled: radio.wifiEnabled,
|
|
496
|
+
mobileDataEnabled: radio.mobileDataEnabled,
|
|
497
|
+
airplaneMode: radio.airplaneMode,
|
|
498
|
+
bluetoothEnabled: radio.bluetoothEnabled,
|
|
499
|
+
},
|
|
500
|
+
display: {
|
|
501
|
+
wmSize: display.wmSize,
|
|
502
|
+
wmDensity: display.wmDensity,
|
|
503
|
+
brightness: display.screenBrightness,
|
|
504
|
+
rotation: display.userRotation,
|
|
505
|
+
},
|
|
506
|
+
location: {
|
|
507
|
+
mode: location.locationMode,
|
|
508
|
+
mockLocation: location.mockLocation,
|
|
509
|
+
appOps: compactStringSummary(location.locationAppOps),
|
|
510
|
+
},
|
|
511
|
+
power: {
|
|
512
|
+
battery: compactStringSummary(power.battery),
|
|
513
|
+
},
|
|
514
|
+
packages: {
|
|
515
|
+
total: packages.packageCount,
|
|
516
|
+
thirdParty: packages.thirdPartyCount,
|
|
517
|
+
system: packages.systemCount,
|
|
518
|
+
disabled: packages.disabledCount,
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
profile.healthScore = computeHealthScore(profile);
|
|
522
|
+
return profile;
|
|
523
|
+
}
|
|
524
|
+
function buildProfilesForDevices(deviceIds, packageName) {
|
|
525
|
+
const startedAt = Date.now();
|
|
526
|
+
const profiles = [];
|
|
527
|
+
for (const deviceId of deviceIds) {
|
|
528
|
+
try {
|
|
529
|
+
const profile = buildDeviceProfile(deviceId, packageName);
|
|
530
|
+
profiles.push({ ok: true, deviceId, profile });
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
534
|
+
profiles.push({ ok: false, deviceId, error: message });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
capturedAt: nowIso(),
|
|
539
|
+
durationMs: Date.now() - startedAt,
|
|
540
|
+
count: profiles.length,
|
|
541
|
+
profiles,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
416
544
|
function runStressScenario(body) {
|
|
417
545
|
const deviceId = extractDeviceId(body);
|
|
418
546
|
const urlsValue = body.urls;
|
|
@@ -422,28 +550,28 @@ function runStressScenario(body) {
|
|
|
422
550
|
const normalizedUrls = urls.length > 0
|
|
423
551
|
? urls
|
|
424
552
|
: ['https://www.wikipedia.org', 'https://news.ycombinator.com', 'https://developer.android.com'];
|
|
425
|
-
const loops = clampInt(body.loops, 1, 1,
|
|
426
|
-
const waitForReadyMs = clampInt(body.waitForReadyMs, 1000, 200,
|
|
553
|
+
const loops = clampInt(body.loops, 1, 1, 6);
|
|
554
|
+
const waitForReadyMs = clampInt(body.waitForReadyMs, 1000, 200, 7000);
|
|
427
555
|
const includeSnapshotAfterEach = body.includeSnapshotAfterEach !== false;
|
|
428
556
|
const startedAt = Date.now();
|
|
429
557
|
const steps = [];
|
|
430
558
|
for (let loop = 1; loop <= loops; loop += 1) {
|
|
431
|
-
for (const
|
|
432
|
-
const
|
|
433
|
-
const open = (0, adb_js_1.openUrlInChrome)(
|
|
559
|
+
for (const targetUrl of normalizedUrls) {
|
|
560
|
+
const openStarted = Date.now();
|
|
561
|
+
const open = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
|
|
434
562
|
waitForReadyMs,
|
|
435
563
|
fallbackToDefault: true,
|
|
436
564
|
});
|
|
437
|
-
const
|
|
565
|
+
const step = {
|
|
438
566
|
kind: 'open_url',
|
|
439
567
|
loop,
|
|
440
|
-
url,
|
|
568
|
+
url: targetUrl,
|
|
441
569
|
strategy: open.strategy,
|
|
442
570
|
deviceId: open.deviceId,
|
|
443
|
-
durationMs: Date.now() -
|
|
571
|
+
durationMs: Date.now() - openStarted,
|
|
444
572
|
};
|
|
445
573
|
if (includeSnapshotAfterEach) {
|
|
446
|
-
const
|
|
574
|
+
const snapStarted = Date.now();
|
|
447
575
|
const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({ deviceId: open.deviceId, includeWifiDump: false });
|
|
448
576
|
const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
|
|
449
577
|
deviceId: open.deviceId,
|
|
@@ -451,8 +579,8 @@ function runStressScenario(body) {
|
|
|
451
579
|
includeWindowDump: false,
|
|
452
580
|
includeSurfaceFlinger: false,
|
|
453
581
|
});
|
|
454
|
-
|
|
455
|
-
durationMs: Date.now() -
|
|
582
|
+
step.snapshot = {
|
|
583
|
+
durationMs: Date.now() - snapStarted,
|
|
456
584
|
radio: {
|
|
457
585
|
wifiEnabled: radio.wifiEnabled,
|
|
458
586
|
mobileDataEnabled: radio.mobileDataEnabled,
|
|
@@ -461,11 +589,11 @@ function runStressScenario(body) {
|
|
|
461
589
|
display: {
|
|
462
590
|
wmSize: display.wmSize,
|
|
463
591
|
wmDensity: display.wmDensity,
|
|
464
|
-
|
|
592
|
+
brightness: display.screenBrightness,
|
|
465
593
|
},
|
|
466
594
|
};
|
|
467
595
|
}
|
|
468
|
-
steps.push(
|
|
596
|
+
steps.push(step);
|
|
469
597
|
}
|
|
470
598
|
}
|
|
471
599
|
return {
|
|
@@ -492,28 +620,27 @@ async function runWorkflow(name, options) {
|
|
|
492
620
|
const stepStarted = Date.now();
|
|
493
621
|
if (step.type === 'open_url') {
|
|
494
622
|
const result = (0, adb_js_1.openUrlInChrome)(step.url || 'https://www.wikipedia.org', options.deviceId, {
|
|
495
|
-
waitForReadyMs: clampInt(step.waitForReadyMs,
|
|
623
|
+
waitForReadyMs: clampInt(step.waitForReadyMs, 1000, 200, 10000),
|
|
496
624
|
fallbackToDefault: true,
|
|
497
625
|
});
|
|
498
626
|
outputs.push({
|
|
499
627
|
step: 'open_url',
|
|
500
628
|
url: step.url,
|
|
501
629
|
strategy: result.strategy,
|
|
502
|
-
deviceId: result.deviceId,
|
|
503
630
|
durationMs: Date.now() - stepStarted,
|
|
504
631
|
});
|
|
505
632
|
continue;
|
|
506
633
|
}
|
|
507
634
|
if (step.type === 'snapshot') {
|
|
508
|
-
const
|
|
509
|
-
const snapshot = captureSnapshot(
|
|
635
|
+
const kind = step.snapshot || 'radio';
|
|
636
|
+
const snapshot = captureSnapshot(kind, {
|
|
510
637
|
deviceId: options.deviceId,
|
|
511
638
|
packageName: step.packageName || options.packageName,
|
|
512
639
|
includeRaw: step.includeRaw === true || options.includeRaw === true,
|
|
513
640
|
});
|
|
514
641
|
outputs.push({
|
|
515
642
|
step: 'snapshot',
|
|
516
|
-
snapshotKind,
|
|
643
|
+
snapshotKind: kind,
|
|
517
644
|
durationMs: Date.now() - stepStarted,
|
|
518
645
|
snapshot,
|
|
519
646
|
});
|
|
@@ -528,12 +655,9 @@ async function runWorkflow(name, options) {
|
|
|
528
655
|
});
|
|
529
656
|
continue;
|
|
530
657
|
}
|
|
531
|
-
const duration = clampInt(step.durationMs, 500, 50,
|
|
658
|
+
const duration = clampInt(step.durationMs, 500, 50, 30000);
|
|
532
659
|
await sleepMs(duration);
|
|
533
|
-
outputs.push({
|
|
534
|
-
step: 'sleep_ms',
|
|
535
|
-
durationMs: duration,
|
|
536
|
-
});
|
|
660
|
+
outputs.push({ step: 'sleep_ms', durationMs: duration });
|
|
537
661
|
}
|
|
538
662
|
return {
|
|
539
663
|
ok: true,
|
|
@@ -548,6 +672,245 @@ async function runWorkflow(name, options) {
|
|
|
548
672
|
updateHint: UPDATE_HINT,
|
|
549
673
|
};
|
|
550
674
|
}
|
|
675
|
+
function resolveLaneForDevice(deviceId) {
|
|
676
|
+
const laneId = deviceId ? `device:${deviceId}` : 'device:default';
|
|
677
|
+
if (!lanes[laneId]) {
|
|
678
|
+
lanes[laneId] = {
|
|
679
|
+
id: laneId,
|
|
680
|
+
deviceId,
|
|
681
|
+
active: false,
|
|
682
|
+
queue: [],
|
|
683
|
+
completed: 0,
|
|
684
|
+
failed: 0,
|
|
685
|
+
cancelled: 0,
|
|
686
|
+
updatedAt: nowIso(),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
return lanes[laneId];
|
|
690
|
+
}
|
|
691
|
+
function createJob(type, input) {
|
|
692
|
+
const rawDeviceId = typeof input.deviceId === 'string' ? input.deviceId : undefined;
|
|
693
|
+
const lane = resolveLaneForDevice(rawDeviceId);
|
|
694
|
+
const job = {
|
|
695
|
+
id: jobSeq++,
|
|
696
|
+
type,
|
|
697
|
+
laneId: lane.id,
|
|
698
|
+
deviceId: lane.deviceId,
|
|
699
|
+
status: 'queued',
|
|
700
|
+
createdAt: nowIso(),
|
|
701
|
+
input,
|
|
702
|
+
};
|
|
703
|
+
jobs.unshift(job);
|
|
704
|
+
if (jobs.length > MAX_JOBS) {
|
|
705
|
+
jobs.splice(MAX_JOBS);
|
|
706
|
+
}
|
|
707
|
+
lane.queue.push(job.id);
|
|
708
|
+
lane.updatedAt = nowIso();
|
|
709
|
+
pushEvent('job-queued', 'Job queued', {
|
|
710
|
+
id: job.id,
|
|
711
|
+
type: job.type,
|
|
712
|
+
laneId: lane.id,
|
|
713
|
+
queueDepth: lane.queue.length,
|
|
714
|
+
});
|
|
715
|
+
void scheduleLanes();
|
|
716
|
+
return job;
|
|
717
|
+
}
|
|
718
|
+
function getJobById(id) {
|
|
719
|
+
return jobs.find(job => job.id === id);
|
|
720
|
+
}
|
|
721
|
+
function listJobSummaries() {
|
|
722
|
+
return jobs
|
|
723
|
+
.slice()
|
|
724
|
+
.sort((a, b) => b.id - a.id)
|
|
725
|
+
.map(job => ({
|
|
726
|
+
id: job.id,
|
|
727
|
+
type: job.type,
|
|
728
|
+
laneId: job.laneId,
|
|
729
|
+
deviceId: job.deviceId,
|
|
730
|
+
status: job.status,
|
|
731
|
+
createdAt: job.createdAt,
|
|
732
|
+
startedAt: job.startedAt,
|
|
733
|
+
finishedAt: job.finishedAt,
|
|
734
|
+
durationMs: job.durationMs,
|
|
735
|
+
error: job.error,
|
|
736
|
+
}));
|
|
737
|
+
}
|
|
738
|
+
function cancelQueuedJob(id) {
|
|
739
|
+
const job = getJobById(id);
|
|
740
|
+
if (!job || job.status !== 'queued') {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
const lane = lanes[job.laneId];
|
|
744
|
+
if (!lane) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
const idx = lane.queue.indexOf(id);
|
|
748
|
+
if (idx >= 0) {
|
|
749
|
+
lane.queue.splice(idx, 1);
|
|
750
|
+
}
|
|
751
|
+
job.status = 'cancelled';
|
|
752
|
+
job.finishedAt = nowIso();
|
|
753
|
+
job.durationMs = 0;
|
|
754
|
+
lane.cancelled += 1;
|
|
755
|
+
lane.updatedAt = nowIso();
|
|
756
|
+
pushEvent('job-cancelled', 'Job cancelled', { id, laneId: lane.id });
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
function retryJob(id) {
|
|
760
|
+
const previous = getJobById(id);
|
|
761
|
+
if (!previous) {
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
if (previous.status !== 'failed' && previous.status !== 'completed' && previous.status !== 'cancelled') {
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
const cloneInput = JSON.parse(JSON.stringify(previous.input));
|
|
768
|
+
return createJob(previous.type, cloneInput);
|
|
769
|
+
}
|
|
770
|
+
async function executeJob(job) {
|
|
771
|
+
if (job.type === 'open_url') {
|
|
772
|
+
const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
|
|
773
|
+
if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
|
|
774
|
+
throw new Error('open_url job requires valid url');
|
|
775
|
+
}
|
|
776
|
+
const waitForReadyMs = clampInt(job.input.waitForReadyMs, 1000, 200, 10000);
|
|
777
|
+
return (0, adb_js_1.openUrlInChrome)(urlValue, job.deviceId, {
|
|
778
|
+
waitForReadyMs,
|
|
779
|
+
fallbackToDefault: true,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
if (job.type === 'snapshot_suite') {
|
|
783
|
+
return getSnapshotSuite(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
|
|
784
|
+
}
|
|
785
|
+
if (job.type === 'stress_run') {
|
|
786
|
+
const input = { ...job.input };
|
|
787
|
+
if (!input.deviceId && job.deviceId) {
|
|
788
|
+
input.deviceId = job.deviceId;
|
|
789
|
+
}
|
|
790
|
+
return runStressScenario(input);
|
|
791
|
+
}
|
|
792
|
+
if (job.type === 'workflow_run') {
|
|
793
|
+
const workflowName = typeof job.input.name === 'string' ? job.input.name : '';
|
|
794
|
+
if (!workflowName) {
|
|
795
|
+
throw new Error('workflow_run job requires name');
|
|
796
|
+
}
|
|
797
|
+
return await runWorkflow(workflowName, {
|
|
798
|
+
deviceId: job.deviceId,
|
|
799
|
+
packageName: typeof job.input.packageName === 'string' ? job.input.packageName : undefined,
|
|
800
|
+
includeRaw: job.input.includeRaw === true,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
return buildDeviceProfile(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
|
|
804
|
+
}
|
|
805
|
+
async function processLane(lane) {
|
|
806
|
+
if (lane.active) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
lane.active = true;
|
|
810
|
+
lane.updatedAt = nowIso();
|
|
811
|
+
while (lane.queue.length > 0) {
|
|
812
|
+
const jobId = lane.queue.shift();
|
|
813
|
+
if (!jobId) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const job = getJobById(jobId);
|
|
817
|
+
if (!job || job.status !== 'queued') {
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
job.status = 'running';
|
|
821
|
+
job.startedAt = nowIso();
|
|
822
|
+
lane.updatedAt = nowIso();
|
|
823
|
+
pushEvent('job-running', 'Job started', {
|
|
824
|
+
id: job.id,
|
|
825
|
+
type: job.type,
|
|
826
|
+
laneId: lane.id,
|
|
827
|
+
});
|
|
828
|
+
const startedAtMs = Date.now();
|
|
829
|
+
try {
|
|
830
|
+
const result = await executeJob(job);
|
|
831
|
+
job.result = result;
|
|
832
|
+
job.status = 'completed';
|
|
833
|
+
job.finishedAt = nowIso();
|
|
834
|
+
job.durationMs = Date.now() - startedAtMs;
|
|
835
|
+
lane.completed += 1;
|
|
836
|
+
lane.updatedAt = nowIso();
|
|
837
|
+
pushEvent('job-completed', 'Job completed', {
|
|
838
|
+
id: job.id,
|
|
839
|
+
type: job.type,
|
|
840
|
+
laneId: lane.id,
|
|
841
|
+
durationMs: job.durationMs,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
846
|
+
job.status = 'failed';
|
|
847
|
+
job.error = message;
|
|
848
|
+
job.finishedAt = nowIso();
|
|
849
|
+
job.durationMs = Date.now() - startedAtMs;
|
|
850
|
+
lane.failed += 1;
|
|
851
|
+
lane.updatedAt = nowIso();
|
|
852
|
+
pushEvent('job-failed', 'Job failed', {
|
|
853
|
+
id: job.id,
|
|
854
|
+
type: job.type,
|
|
855
|
+
laneId: lane.id,
|
|
856
|
+
error: message,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
lane.active = false;
|
|
861
|
+
lane.updatedAt = nowIso();
|
|
862
|
+
}
|
|
863
|
+
let laneSchedulerRunning = false;
|
|
864
|
+
async function scheduleLanes() {
|
|
865
|
+
if (laneSchedulerRunning) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
laneSchedulerRunning = true;
|
|
869
|
+
const promises = [];
|
|
870
|
+
for (const lane of Object.values(lanes)) {
|
|
871
|
+
if (lane.queue.length > 0 && !lane.active) {
|
|
872
|
+
promises.push(processLane(lane));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
await Promise.all(promises);
|
|
876
|
+
laneSchedulerRunning = false;
|
|
877
|
+
const hasPending = Object.values(lanes).some(lane => lane.queue.length > 0 && !lane.active);
|
|
878
|
+
if (hasPending) {
|
|
879
|
+
void scheduleLanes();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function lanesSummary() {
|
|
883
|
+
return Object.values(lanes)
|
|
884
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
885
|
+
.map(lane => ({
|
|
886
|
+
id: lane.id,
|
|
887
|
+
deviceId: lane.deviceId,
|
|
888
|
+
active: lane.active,
|
|
889
|
+
queueDepth: lane.queue.length,
|
|
890
|
+
completed: lane.completed,
|
|
891
|
+
failed: lane.failed,
|
|
892
|
+
cancelled: lane.cancelled,
|
|
893
|
+
updatedAt: lane.updatedAt,
|
|
894
|
+
}));
|
|
895
|
+
}
|
|
896
|
+
function resetSession(options) {
|
|
897
|
+
eventHistory.splice(0, eventHistory.length);
|
|
898
|
+
for (const key of Object.keys(metrics)) {
|
|
899
|
+
delete metrics[key];
|
|
900
|
+
}
|
|
901
|
+
for (const key of Object.keys(snapshotCache)) {
|
|
902
|
+
delete snapshotCache[key];
|
|
903
|
+
}
|
|
904
|
+
jobs.splice(0, jobs.length);
|
|
905
|
+
for (const key of Object.keys(lanes)) {
|
|
906
|
+
delete lanes[key];
|
|
907
|
+
}
|
|
908
|
+
if (!options.keepWorkflows) {
|
|
909
|
+
workflows = defaultWorkflows();
|
|
910
|
+
saveWorkflows();
|
|
911
|
+
}
|
|
912
|
+
pushEvent('session-reset', 'Session state reset', { keepWorkflows: options.keepWorkflows !== false });
|
|
913
|
+
}
|
|
551
914
|
async function readJsonBody(request) {
|
|
552
915
|
return await new Promise((resolve, reject) => {
|
|
553
916
|
const chunks = [];
|
|
@@ -639,23 +1002,43 @@ function buildStatePayload(host, port) {
|
|
|
639
1002
|
connectedDeviceCount: devices.length,
|
|
640
1003
|
eventCount: eventHistory.length,
|
|
641
1004
|
workflowCount: Object.keys(workflows).length,
|
|
1005
|
+
laneCount: Object.keys(lanes).length,
|
|
1006
|
+
queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
|
|
1007
|
+
jobCount: jobs.length,
|
|
642
1008
|
snapshotKinds: SNAPSHOT_KINDS,
|
|
643
1009
|
};
|
|
644
1010
|
}
|
|
645
1011
|
function buildMetricsPayload() {
|
|
646
|
-
const
|
|
1012
|
+
const entries = Object.values(metrics)
|
|
647
1013
|
.sort((a, b) => b.count - a.count)
|
|
648
|
-
.map(
|
|
649
|
-
...
|
|
650
|
-
avgDurationMs:
|
|
651
|
-
successRate:
|
|
1014
|
+
.map(entry => ({
|
|
1015
|
+
...entry,
|
|
1016
|
+
avgDurationMs: entry.count > 0 ? Math.round((entry.totalDurationMs / entry.count) * 100) / 100 : 0,
|
|
1017
|
+
successRate: entry.count > 0 ? Math.round((entry.success / entry.count) * 10000) / 100 : 0,
|
|
652
1018
|
}));
|
|
653
1019
|
return {
|
|
654
1020
|
generatedAt: nowIso(),
|
|
655
1021
|
uptimeMs: Date.now() - serverStartedAt,
|
|
656
|
-
totalActions:
|
|
657
|
-
errors:
|
|
658
|
-
entries
|
|
1022
|
+
totalActions: entries.reduce((sum, item) => sum + item.count, 0),
|
|
1023
|
+
errors: entries.reduce((sum, item) => sum + item.errors, 0),
|
|
1024
|
+
entries,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
function buildSessionExport() {
|
|
1028
|
+
return {
|
|
1029
|
+
exportedAt: nowIso(),
|
|
1030
|
+
state: {
|
|
1031
|
+
uptimeMs: Date.now() - serverStartedAt,
|
|
1032
|
+
workflowCount: Object.keys(workflows).length,
|
|
1033
|
+
jobCount: jobs.length,
|
|
1034
|
+
laneCount: Object.keys(lanes).length,
|
|
1035
|
+
eventCount: eventHistory.length,
|
|
1036
|
+
},
|
|
1037
|
+
workflows,
|
|
1038
|
+
lanes: lanesSummary(),
|
|
1039
|
+
jobs,
|
|
1040
|
+
metrics: buildMetricsPayload(),
|
|
1041
|
+
events: eventHistory,
|
|
659
1042
|
};
|
|
660
1043
|
}
|
|
661
1044
|
async function handleApi(request, response, context) {
|
|
@@ -691,34 +1074,186 @@ async function handleApi(request, response, context) {
|
|
|
691
1074
|
});
|
|
692
1075
|
return;
|
|
693
1076
|
}
|
|
694
|
-
if (method === 'GET' && pathname === '/api/
|
|
695
|
-
await withMetric('
|
|
696
|
-
const limitRaw = Number(url.searchParams.get('limit') ?? '120');
|
|
697
|
-
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(500, Math.trunc(limitRaw))) : 120;
|
|
1077
|
+
if (method === 'GET' && pathname === '/api/lanes') {
|
|
1078
|
+
await withMetric('lanes-list', () => {
|
|
698
1079
|
sendJson(response, 200, {
|
|
699
|
-
|
|
700
|
-
|
|
1080
|
+
lanes: lanesSummary(),
|
|
1081
|
+
count: Object.keys(lanes).length,
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (method === 'GET' && pathname === '/api/jobs') {
|
|
1087
|
+
await withMetric('jobs-list', () => {
|
|
1088
|
+
sendJson(response, 200, {
|
|
1089
|
+
jobs: listJobSummaries(),
|
|
1090
|
+
queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (method === 'POST' && pathname === '/api/jobs') {
|
|
1096
|
+
await withMetric('jobs-create', async () => {
|
|
1097
|
+
const body = await readJsonBody(request);
|
|
1098
|
+
const typeValue = typeof body.type === 'string' ? body.type : '';
|
|
1099
|
+
if (!isJobType(typeValue)) {
|
|
1100
|
+
sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run, device_profile' });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
|
|
1104
|
+
? body.input
|
|
1105
|
+
: {};
|
|
1106
|
+
const job = createJob(typeValue, input);
|
|
1107
|
+
sendJson(response, 200, {
|
|
1108
|
+
ok: true,
|
|
1109
|
+
job,
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (method === 'POST' && pathname === '/api/jobs/bulk') {
|
|
1115
|
+
await withMetric('jobs-bulk', async () => {
|
|
1116
|
+
const body = await readJsonBody(request);
|
|
1117
|
+
const itemsRaw = Array.isArray(body.jobs) ? body.jobs : [];
|
|
1118
|
+
if (itemsRaw.length === 0) {
|
|
1119
|
+
sendJson(response, 400, { error: 'jobs array is required' });
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const created = [];
|
|
1123
|
+
for (const itemRaw of itemsRaw) {
|
|
1124
|
+
if (!itemRaw || typeof itemRaw !== 'object') {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const item = itemRaw;
|
|
1128
|
+
const typeValue = typeof item.type === 'string' ? item.type : '';
|
|
1129
|
+
if (!isJobType(typeValue)) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const input = item.input && typeof item.input === 'object' && !Array.isArray(item.input)
|
|
1133
|
+
? item.input
|
|
1134
|
+
: {};
|
|
1135
|
+
created.push(createJob(typeValue, input));
|
|
1136
|
+
}
|
|
1137
|
+
sendJson(response, 200, {
|
|
1138
|
+
ok: true,
|
|
1139
|
+
createdCount: created.length,
|
|
1140
|
+
jobs: created,
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (method === 'GET' && /^\/api\/jobs\/\d+$/.test(pathname)) {
|
|
1146
|
+
await withMetric('jobs-get', () => {
|
|
1147
|
+
const id = Number(pathname.slice('/api/jobs/'.length));
|
|
1148
|
+
const job = getJobById(id);
|
|
1149
|
+
if (!job) {
|
|
1150
|
+
sendJson(response, 404, { error: `job ${id} not found` });
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
sendJson(response, 200, { ok: true, job });
|
|
1154
|
+
});
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (method === 'POST' && /^\/api\/jobs\/\d+\/cancel$/.test(pathname)) {
|
|
1158
|
+
await withMetric('jobs-cancel', () => {
|
|
1159
|
+
const id = Number(pathname.split('/')[3]);
|
|
1160
|
+
const ok = cancelQueuedJob(id);
|
|
1161
|
+
if (!ok) {
|
|
1162
|
+
sendJson(response, 400, { error: `job ${id} cannot be cancelled` });
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
sendJson(response, 200, { ok: true, id });
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (method === 'POST' && /^\/api\/jobs\/\d+\/retry$/.test(pathname)) {
|
|
1170
|
+
await withMetric('jobs-retry', () => {
|
|
1171
|
+
const id = Number(pathname.split('/')[3]);
|
|
1172
|
+
const retried = retryJob(id);
|
|
1173
|
+
if (!retried) {
|
|
1174
|
+
sendJson(response, 400, { error: `job ${id} cannot be retried` });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
sendJson(response, 200, {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
previousJobId: id,
|
|
1180
|
+
newJob: retried,
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (method === 'GET' && pathname === '/api/workflows') {
|
|
1186
|
+
await withMetric('workflows-list', () => {
|
|
1187
|
+
sendJson(response, 200, {
|
|
1188
|
+
workflows: workflowsList(),
|
|
1189
|
+
count: Object.keys(workflows).length,
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (method === 'GET' && pathname === '/api/workflows/export') {
|
|
1195
|
+
await withMetric('workflows-export', () => {
|
|
1196
|
+
sendJson(response, 200, {
|
|
1197
|
+
exportedAt: nowIso(),
|
|
1198
|
+
workflows,
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (method === 'POST' && pathname === '/api/workflows/import') {
|
|
1204
|
+
await withMetric('workflows-import', async () => {
|
|
1205
|
+
const body = await readJsonBody(request);
|
|
1206
|
+
const replace = body.replace === true;
|
|
1207
|
+
const payload = body.workflows;
|
|
1208
|
+
const imported = replace ? {} : { ...workflows };
|
|
1209
|
+
const consumeWorkflow = (value) => {
|
|
1210
|
+
if (!value || typeof value !== 'object') {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const item = value;
|
|
1214
|
+
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
1215
|
+
if (!name) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
|
|
1219
|
+
const steps = [];
|
|
1220
|
+
for (const stepRaw of stepsRaw) {
|
|
1221
|
+
steps.push(normalizeWorkflowStep(stepRaw));
|
|
1222
|
+
}
|
|
1223
|
+
if (steps.length === 0) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
imported[name] = {
|
|
1227
|
+
name,
|
|
1228
|
+
description: typeof item.description === 'string' ? item.description : undefined,
|
|
1229
|
+
updatedAt: nowIso(),
|
|
1230
|
+
steps,
|
|
1231
|
+
};
|
|
1232
|
+
};
|
|
1233
|
+
if (Array.isArray(payload)) {
|
|
1234
|
+
for (const value of payload) {
|
|
1235
|
+
consumeWorkflow(value);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
else if (payload && typeof payload === 'object') {
|
|
1239
|
+
for (const value of Object.values(payload)) {
|
|
1240
|
+
consumeWorkflow(value);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
sendJson(response, 400, { error: 'workflows must be array or object' });
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
workflows = imported;
|
|
1248
|
+
saveWorkflows();
|
|
1249
|
+
pushEvent('workflow-import', 'Workflows imported', {
|
|
1250
|
+
count: Object.keys(workflows).length,
|
|
1251
|
+
replace,
|
|
701
1252
|
});
|
|
702
|
-
});
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
if (method === 'GET' && pathname === '/api/events') {
|
|
706
|
-
await withMetric('events', () => {
|
|
707
|
-
setupSse(request, response);
|
|
708
|
-
});
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
if (method === 'GET' && pathname === '/api/metrics') {
|
|
712
|
-
await withMetric('metrics', () => {
|
|
713
|
-
sendJson(response, 200, buildMetricsPayload());
|
|
714
|
-
});
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
if (method === 'GET' && pathname === '/api/workflows') {
|
|
718
|
-
await withMetric('workflows-list', () => {
|
|
719
1253
|
sendJson(response, 200, {
|
|
720
|
-
|
|
1254
|
+
ok: true,
|
|
721
1255
|
count: Object.keys(workflows).length,
|
|
1256
|
+
workflows: workflowsList(),
|
|
722
1257
|
});
|
|
723
1258
|
});
|
|
724
1259
|
return;
|
|
@@ -738,13 +1273,12 @@ async function handleApi(request, response, context) {
|
|
|
738
1273
|
return;
|
|
739
1274
|
}
|
|
740
1275
|
const steps = stepsRaw.map(step => normalizeWorkflowStep(step));
|
|
741
|
-
|
|
1276
|
+
workflows[name] = {
|
|
742
1277
|
name,
|
|
743
1278
|
description,
|
|
744
1279
|
updatedAt: nowIso(),
|
|
745
1280
|
steps,
|
|
746
1281
|
};
|
|
747
|
-
workflows[name] = workflow;
|
|
748
1282
|
saveWorkflows();
|
|
749
1283
|
pushEvent('workflow-saved', 'Workflow saved', {
|
|
750
1284
|
name,
|
|
@@ -752,7 +1286,7 @@ async function handleApi(request, response, context) {
|
|
|
752
1286
|
});
|
|
753
1287
|
sendJson(response, 200, {
|
|
754
1288
|
ok: true,
|
|
755
|
-
workflow,
|
|
1289
|
+
workflow: workflows[name],
|
|
756
1290
|
});
|
|
757
1291
|
});
|
|
758
1292
|
return;
|
|
@@ -804,11 +1338,9 @@ async function handleApi(request, response, context) {
|
|
|
804
1338
|
sendJson(response, 400, { error: 'url must be valid http/https URL' });
|
|
805
1339
|
return;
|
|
806
1340
|
}
|
|
807
|
-
const deviceId = extractDeviceId(body);
|
|
808
|
-
const waitForReadyMs = clampInt(body.waitForReadyMs, 1200, 200, 10000);
|
|
809
1341
|
const startedAt = Date.now();
|
|
810
|
-
const result = (0, adb_js_1.openUrlInChrome)(targetUrl,
|
|
811
|
-
waitForReadyMs,
|
|
1342
|
+
const result = (0, adb_js_1.openUrlInChrome)(targetUrl, extractDeviceId(body), {
|
|
1343
|
+
waitForReadyMs: clampInt(body.waitForReadyMs, 1000, 200, 10000),
|
|
812
1344
|
fallbackToDefault: true,
|
|
813
1345
|
});
|
|
814
1346
|
pushEvent('open-url', 'Opened URL on device', {
|
|
@@ -826,6 +1358,44 @@ async function handleApi(request, response, context) {
|
|
|
826
1358
|
});
|
|
827
1359
|
return;
|
|
828
1360
|
}
|
|
1361
|
+
if (method === 'POST' && pathname === '/api/device/profile') {
|
|
1362
|
+
await withMetric('device-profile', async () => {
|
|
1363
|
+
const body = await readJsonBody(request);
|
|
1364
|
+
const profile = buildDeviceProfile(extractDeviceId(body), typeof body.packageName === 'string' ? body.packageName : undefined);
|
|
1365
|
+
pushEvent('device-profile', 'Device profile captured', {
|
|
1366
|
+
deviceId: profile.deviceId,
|
|
1367
|
+
durationMs: profile.durationMs,
|
|
1368
|
+
healthScore: profile.healthScore,
|
|
1369
|
+
});
|
|
1370
|
+
sendJson(response, 200, {
|
|
1371
|
+
ok: true,
|
|
1372
|
+
profile,
|
|
1373
|
+
updateHint: UPDATE_HINT,
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (method === 'POST' && pathname === '/api/device/profiles') {
|
|
1379
|
+
await withMetric('device-profiles', async () => {
|
|
1380
|
+
const body = await readJsonBody(request);
|
|
1381
|
+
const packageName = typeof body.packageName === 'string' ? body.packageName : undefined;
|
|
1382
|
+
const devicesRaw = Array.isArray(body.deviceIds) ? body.deviceIds : [];
|
|
1383
|
+
const requested = devicesRaw.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
1384
|
+
const fallback = (0, adb_js_1.getConnectedDevices)().map(device => device.id);
|
|
1385
|
+
const targetDevices = requested.length > 0 ? requested : fallback;
|
|
1386
|
+
const profiles = buildProfilesForDevices(targetDevices, packageName);
|
|
1387
|
+
pushEvent('device-profiles', 'Multi-device profiles captured', {
|
|
1388
|
+
count: profiles.count,
|
|
1389
|
+
durationMs: profiles.durationMs,
|
|
1390
|
+
});
|
|
1391
|
+
sendJson(response, 200, {
|
|
1392
|
+
ok: true,
|
|
1393
|
+
...profiles,
|
|
1394
|
+
updateHint: UPDATE_HINT,
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
829
1399
|
if (method === 'POST' && pathname === '/api/snapshot-suite') {
|
|
830
1400
|
await withMetric('snapshot-suite', async () => {
|
|
831
1401
|
const body = await readJsonBody(request);
|
|
@@ -849,23 +1419,23 @@ async function handleApi(request, response, context) {
|
|
|
849
1419
|
sendJson(response, 400, { error: 'kind must be one of snapshot kinds' });
|
|
850
1420
|
return;
|
|
851
1421
|
}
|
|
852
|
-
const
|
|
1422
|
+
const previous = snapshotCache[kindValue];
|
|
853
1423
|
const fresh = captureSnapshot(kindValue, {
|
|
854
1424
|
deviceId: extractDeviceId(body),
|
|
855
1425
|
packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
|
|
856
1426
|
includeRaw: true,
|
|
857
1427
|
});
|
|
858
|
-
const currentRaw = fresh.raw
|
|
859
|
-
snapshotCache[kindValue] = currentRaw
|
|
860
|
-
const diff = diffSnapshot(
|
|
861
|
-
pushEvent('snapshot-diff', '
|
|
1428
|
+
const currentRaw = fresh.raw;
|
|
1429
|
+
snapshotCache[kindValue] = currentRaw;
|
|
1430
|
+
const diff = diffSnapshot(previous, currentRaw);
|
|
1431
|
+
pushEvent('snapshot-diff', 'Snapshot diff captured', {
|
|
862
1432
|
kind: kindValue,
|
|
863
1433
|
changedCount: diff.changedCount,
|
|
864
1434
|
});
|
|
865
1435
|
sendJson(response, 200, {
|
|
866
1436
|
ok: true,
|
|
867
1437
|
kind: kindValue,
|
|
868
|
-
hadPrevious: Boolean(
|
|
1438
|
+
hadPrevious: Boolean(previous),
|
|
869
1439
|
diff,
|
|
870
1440
|
currentSummary: fresh.summary,
|
|
871
1441
|
updateHint: UPDATE_HINT,
|
|
@@ -886,7 +1456,7 @@ async function handleApi(request, response, context) {
|
|
|
886
1456
|
packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
|
|
887
1457
|
includeRaw: body.includeRaw === true,
|
|
888
1458
|
});
|
|
889
|
-
pushEvent('snapshot', '
|
|
1459
|
+
pushEvent('snapshot', 'Snapshot captured', {
|
|
890
1460
|
kind: kindValue,
|
|
891
1461
|
deviceId: result.deviceId,
|
|
892
1462
|
});
|
|
@@ -902,7 +1472,7 @@ async function handleApi(request, response, context) {
|
|
|
902
1472
|
await withMetric('stress-run', async () => {
|
|
903
1473
|
const body = await readJsonBody(request);
|
|
904
1474
|
const result = runStressScenario(body);
|
|
905
|
-
pushEvent('stress-run', '
|
|
1475
|
+
pushEvent('stress-run', 'Stress run completed', {
|
|
906
1476
|
totalSteps: result.totalSteps,
|
|
907
1477
|
durationMs: result.durationMs,
|
|
908
1478
|
});
|
|
@@ -910,6 +1480,47 @@ async function handleApi(request, response, context) {
|
|
|
910
1480
|
});
|
|
911
1481
|
return;
|
|
912
1482
|
}
|
|
1483
|
+
if (method === 'GET' && pathname === '/api/history') {
|
|
1484
|
+
await withMetric('history', () => {
|
|
1485
|
+
const limitRaw = Number(url.searchParams.get('limit') ?? '120');
|
|
1486
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(600, Math.trunc(limitRaw))) : 120;
|
|
1487
|
+
sendJson(response, 200, {
|
|
1488
|
+
count: Math.min(limit, eventHistory.length),
|
|
1489
|
+
events: eventHistory.slice(-limit),
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (method === 'GET' && pathname === '/api/events') {
|
|
1495
|
+
await withMetric('events', () => {
|
|
1496
|
+
setupSse(request, response);
|
|
1497
|
+
});
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (method === 'GET' && pathname === '/api/metrics') {
|
|
1501
|
+
await withMetric('metrics', () => {
|
|
1502
|
+
sendJson(response, 200, buildMetricsPayload());
|
|
1503
|
+
});
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (method === 'GET' && pathname === '/api/session/export') {
|
|
1507
|
+
await withMetric('session-export', () => {
|
|
1508
|
+
sendJson(response, 200, buildSessionExport());
|
|
1509
|
+
});
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (method === 'POST' && pathname === '/api/session/reset') {
|
|
1513
|
+
await withMetric('session-reset', async () => {
|
|
1514
|
+
const body = await readJsonBody(request);
|
|
1515
|
+
const keepWorkflows = body.keepWorkflows !== false;
|
|
1516
|
+
resetSession({ keepWorkflows });
|
|
1517
|
+
sendJson(response, 200, {
|
|
1518
|
+
ok: true,
|
|
1519
|
+
keepWorkflows,
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
913
1524
|
sendJson(response, 404, { error: 'Not found' });
|
|
914
1525
|
}
|
|
915
1526
|
const INDEX_HTML = String.raw `<!doctype html>
|
|
@@ -917,7 +1528,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
917
1528
|
<head>
|
|
918
1529
|
<meta charset="utf-8" />
|
|
919
1530
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
920
|
-
<title>the-android-mcp web ui v3.
|
|
1531
|
+
<title>the-android-mcp web ui v3.4</title>
|
|
921
1532
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
922
1533
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
923
1534
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
@@ -948,7 +1559,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
948
1559
|
linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
|
|
949
1560
|
padding: 16px;
|
|
950
1561
|
}
|
|
951
|
-
.app { max-width:
|
|
1562
|
+
.app { max-width: 1560px; margin: 0 auto; display: grid; gap: 12px; }
|
|
952
1563
|
.hero {
|
|
953
1564
|
border: 1px solid var(--line);
|
|
954
1565
|
border-radius: 18px;
|
|
@@ -978,7 +1589,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
978
1589
|
.grid {
|
|
979
1590
|
display: grid;
|
|
980
1591
|
gap: 10px;
|
|
981
|
-
grid-template-columns:
|
|
1592
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
982
1593
|
}
|
|
983
1594
|
.card {
|
|
984
1595
|
border: 1px solid var(--line);
|
|
@@ -1000,13 +1611,13 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1000
1611
|
padding: 9px;
|
|
1001
1612
|
font: inherit;
|
|
1002
1613
|
}
|
|
1003
|
-
textarea { min-height:
|
|
1614
|
+
textarea { min-height: 86px; resize: vertical; }
|
|
1004
1615
|
button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
|
|
1005
1616
|
button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
|
1006
1617
|
.p { background: linear-gradient(130deg, #0d7666, #155f75); }
|
|
1007
1618
|
.s { background: linear-gradient(130deg, #1d3348, #192b3c); }
|
|
1008
1619
|
.w { background: linear-gradient(130deg, #7a5917, #61401f); }
|
|
1009
|
-
.events, .metrics {
|
|
1620
|
+
.events, .metrics, .jobs, .lanes {
|
|
1010
1621
|
max-height: 300px;
|
|
1011
1622
|
overflow: auto;
|
|
1012
1623
|
border: 1px solid #2f4a61;
|
|
@@ -1014,7 +1625,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1014
1625
|
padding: 8px;
|
|
1015
1626
|
background: #0b141d;
|
|
1016
1627
|
}
|
|
1017
|
-
.
|
|
1628
|
+
.item {
|
|
1018
1629
|
border: 1px solid #2e4a61;
|
|
1019
1630
|
border-radius: 8px;
|
|
1020
1631
|
padding: 6px;
|
|
@@ -1038,7 +1649,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1038
1649
|
pre {
|
|
1039
1650
|
margin: 0;
|
|
1040
1651
|
padding: 10px;
|
|
1041
|
-
max-height:
|
|
1652
|
+
max-height: 460px;
|
|
1042
1653
|
overflow: auto;
|
|
1043
1654
|
color: #c6e4ff;
|
|
1044
1655
|
font: 12px/1.45 'JetBrains Mono', monospace;
|
|
@@ -1058,15 +1669,17 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1058
1669
|
<span class="pill" id="version-pill">v3</span>
|
|
1059
1670
|
<span class="pill" id="device-pill">device: n/a</span>
|
|
1060
1671
|
<span class="pill" id="workflow-pill">workflows: 0</span>
|
|
1672
|
+
<span class="pill" id="lane-pill">lanes: 0</span>
|
|
1673
|
+
<span class="pill" id="queue-pill">queue: 0</span>
|
|
1061
1674
|
<span class="pill">port: 50000</span>
|
|
1062
1675
|
</div>
|
|
1063
|
-
<h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.
|
|
1064
|
-
<p class="muted">
|
|
1676
|
+
<h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.4 command center</h1>
|
|
1677
|
+
<p class="muted">Multi-lane backend orchestration, workflow import/export, profile matrix, snapshot diff, and live job controls.</p>
|
|
1065
1678
|
</section>
|
|
1066
1679
|
|
|
1067
1680
|
<section class="grid">
|
|
1068
1681
|
<article class="card stack">
|
|
1069
|
-
<h2>Device
|
|
1682
|
+
<h2>Device operations</h2>
|
|
1070
1683
|
<select id="device-select"></select>
|
|
1071
1684
|
<input id="url-input" type="url" value="https://www.wikipedia.org" />
|
|
1072
1685
|
<div class="split">
|
|
@@ -1079,11 +1692,15 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1079
1692
|
<button class="s quick-url" data-url="https://developer.android.com">Android Docs</button>
|
|
1080
1693
|
<button class="s quick-url" data-url="https://www.youtube.com">YouTube</button>
|
|
1081
1694
|
</div>
|
|
1695
|
+
<div class="split">
|
|
1696
|
+
<button class="s" id="profile-btn">Device profile</button>
|
|
1697
|
+
<button class="s" id="profiles-btn">Profiles all</button>
|
|
1698
|
+
</div>
|
|
1082
1699
|
<p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
|
|
1083
1700
|
</article>
|
|
1084
1701
|
|
|
1085
1702
|
<article class="card stack">
|
|
1086
|
-
<h2>Snapshot +
|
|
1703
|
+
<h2>Snapshot + stress</h2>
|
|
1087
1704
|
<select id="snapshot-kind">
|
|
1088
1705
|
<option value="radio">radio</option>
|
|
1089
1706
|
<option value="display">display</option>
|
|
@@ -1095,48 +1712,74 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1095
1712
|
<button class="s" id="snapshot-btn">Capture</button>
|
|
1096
1713
|
<button class="w" id="snapshot-diff-btn">Capture diff</button>
|
|
1097
1714
|
</div>
|
|
1098
|
-
<p class="muted">Diff compares latest capture with previous capture of same type.</p>
|
|
1099
|
-
</article>
|
|
1100
|
-
|
|
1101
|
-
<article class="card stack">
|
|
1102
|
-
<h2>Stress run</h2>
|
|
1103
1715
|
<textarea id="stress-urls">https://www.wikipedia.org
|
|
1104
1716
|
https://news.ycombinator.com
|
|
1105
1717
|
https://developer.android.com</textarea>
|
|
1106
1718
|
<div class="split">
|
|
1107
|
-
<input id="stress-loops" type="number" min="1" max="
|
|
1108
|
-
<input id="stress-wait" type="number" min="200" max="
|
|
1719
|
+
<input id="stress-loops" type="number" min="1" max="6" value="1" />
|
|
1720
|
+
<input id="stress-wait" type="number" min="200" max="7000" value="1000" />
|
|
1109
1721
|
</div>
|
|
1110
1722
|
<button class="w" id="stress-btn">Run stress</button>
|
|
1111
|
-
<p class="muted">Config: loops / wait-ms</p>
|
|
1112
1723
|
</article>
|
|
1113
1724
|
|
|
1114
1725
|
<article class="card stack">
|
|
1115
1726
|
<h2>Workflow engine</h2>
|
|
1116
1727
|
<select id="workflow-select"></select>
|
|
1117
|
-
<input id="workflow-name" placeholder="workflow name"
|
|
1728
|
+
<input id="workflow-name" placeholder="workflow name" />
|
|
1118
1729
|
<textarea id="workflow-steps">[
|
|
1119
|
-
{"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":
|
|
1730
|
+
{"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":900},
|
|
1120
1731
|
{"type":"snapshot","snapshot":"radio"},
|
|
1121
|
-
{"type":"open_url","url":"https://developer.android.com","waitForReadyMs":1000},
|
|
1122
1732
|
{"type":"snapshot_suite","packageName":"com.android.chrome"}
|
|
1123
1733
|
]</textarea>
|
|
1124
1734
|
<div class="split">
|
|
1125
1735
|
<button class="p" id="workflow-save-btn">Save</button>
|
|
1126
1736
|
<button class="p" id="workflow-run-btn">Run</button>
|
|
1127
1737
|
</div>
|
|
1128
|
-
<
|
|
1738
|
+
<div class="split">
|
|
1739
|
+
<button class="s" id="workflow-delete-btn">Delete</button>
|
|
1740
|
+
<button class="s" id="workflow-export-btn">Export</button>
|
|
1741
|
+
</div>
|
|
1742
|
+
<button class="s" id="workflow-import-btn">Import from editor</button>
|
|
1129
1743
|
</article>
|
|
1130
1744
|
|
|
1131
1745
|
<article class="card stack">
|
|
1132
|
-
<h2>
|
|
1746
|
+
<h2>Job orchestrator</h2>
|
|
1747
|
+
<select id="job-type">
|
|
1748
|
+
<option value="open_url">open_url</option>
|
|
1749
|
+
<option value="snapshot_suite">snapshot_suite</option>
|
|
1750
|
+
<option value="stress_run">stress_run</option>
|
|
1751
|
+
<option value="workflow_run">workflow_run</option>
|
|
1752
|
+
<option value="device_profile">device_profile</option>
|
|
1753
|
+
</select>
|
|
1754
|
+
<input id="job-url" type="url" value="https://developer.android.com" />
|
|
1755
|
+
<select id="job-workflow"></select>
|
|
1756
|
+
<div class="split">
|
|
1757
|
+
<button class="p" id="job-enqueue-btn">Enqueue job</button>
|
|
1758
|
+
<button class="s" id="job-refresh-btn">Refresh jobs</button>
|
|
1759
|
+
</div>
|
|
1760
|
+
<textarea id="bulk-jobs">[
|
|
1761
|
+
{"type":"open_url","input":{"url":"https://www.wikipedia.org","waitForReadyMs":800}},
|
|
1762
|
+
{"type":"open_url","input":{"url":"https://news.ycombinator.com","waitForReadyMs":800}},
|
|
1763
|
+
{"type":"snapshot_suite","input":{"packageName":"com.android.chrome"}}
|
|
1764
|
+
]</textarea>
|
|
1765
|
+
<button class="w" id="job-bulk-btn">Enqueue bulk JSON</button>
|
|
1766
|
+
<div id="jobs" class="jobs"></div>
|
|
1767
|
+
</article>
|
|
1768
|
+
|
|
1769
|
+
<article class="card stack">
|
|
1770
|
+
<h2>Lanes + events</h2>
|
|
1771
|
+
<div id="lanes" class="lanes"></div>
|
|
1133
1772
|
<div id="events" class="events"></div>
|
|
1134
1773
|
</article>
|
|
1135
1774
|
|
|
1136
1775
|
<article class="card stack">
|
|
1137
|
-
<h2>
|
|
1776
|
+
<h2>Metrics + session</h2>
|
|
1138
1777
|
<div id="metrics" class="metrics"></div>
|
|
1139
|
-
<
|
|
1778
|
+
<div class="split">
|
|
1779
|
+
<button class="s" id="refresh-state-btn">Refresh state</button>
|
|
1780
|
+
<button class="s" id="export-session-btn">Export session</button>
|
|
1781
|
+
</div>
|
|
1782
|
+
<button class="w" id="reset-session-btn">Reset session (keep workflows)</button>
|
|
1140
1783
|
</article>
|
|
1141
1784
|
|
|
1142
1785
|
<article class="card" style="grid-column: 1 / -1;">
|
|
@@ -1161,12 +1804,16 @@ https://developer.android.com</textarea>
|
|
|
1161
1804
|
const $versionPill = document.getElementById('version-pill');
|
|
1162
1805
|
const $devicePill = document.getElementById('device-pill');
|
|
1163
1806
|
const $workflowPill = document.getElementById('workflow-pill');
|
|
1807
|
+
const $lanePill = document.getElementById('lane-pill');
|
|
1808
|
+
const $queuePill = document.getElementById('queue-pill');
|
|
1164
1809
|
const $deviceSelect = document.getElementById('device-select');
|
|
1165
1810
|
const $urlInput = document.getElementById('url-input');
|
|
1166
1811
|
const $message = document.getElementById('message');
|
|
1167
1812
|
const $output = document.getElementById('output');
|
|
1168
1813
|
const $events = document.getElementById('events');
|
|
1169
1814
|
const $metrics = document.getElementById('metrics');
|
|
1815
|
+
const $jobs = document.getElementById('jobs');
|
|
1816
|
+
const $lanes = document.getElementById('lanes');
|
|
1170
1817
|
const $snapshotKind = document.getElementById('snapshot-kind');
|
|
1171
1818
|
const $stressUrls = document.getElementById('stress-urls');
|
|
1172
1819
|
const $stressLoops = document.getElementById('stress-loops');
|
|
@@ -1174,6 +1821,10 @@ https://developer.android.com</textarea>
|
|
|
1174
1821
|
const $workflowSelect = document.getElementById('workflow-select');
|
|
1175
1822
|
const $workflowName = document.getElementById('workflow-name');
|
|
1176
1823
|
const $workflowSteps = document.getElementById('workflow-steps');
|
|
1824
|
+
const $jobType = document.getElementById('job-type');
|
|
1825
|
+
const $jobUrl = document.getElementById('job-url');
|
|
1826
|
+
const $jobWorkflow = document.getElementById('job-workflow');
|
|
1827
|
+
const $bulkJobs = document.getElementById('bulk-jobs');
|
|
1177
1828
|
|
|
1178
1829
|
function setMessage(text, isError) {
|
|
1179
1830
|
$message.textContent = text;
|
|
@@ -1190,23 +1841,13 @@ https://developer.android.com</textarea>
|
|
|
1190
1841
|
return;
|
|
1191
1842
|
}
|
|
1192
1843
|
const item = document.createElement('div');
|
|
1193
|
-
item.className = '
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
message.textContent = event.message || '';
|
|
1199
|
-
item.appendChild(meta);
|
|
1200
|
-
item.appendChild(message);
|
|
1201
|
-
if (event.data) {
|
|
1202
|
-
const data = document.createElement('div');
|
|
1203
|
-
data.style.color = '#a5c6db';
|
|
1204
|
-
data.style.marginTop = '4px';
|
|
1205
|
-
data.textContent = JSON.stringify(event.data);
|
|
1206
|
-
item.appendChild(data);
|
|
1207
|
-
}
|
|
1844
|
+
item.className = 'item';
|
|
1845
|
+
item.innerHTML =
|
|
1846
|
+
'<div class="meta">[' + (event.at || new Date().toISOString()) + '] ' + event.type + '</div>' +
|
|
1847
|
+
'<div>' + (event.message || '') + '</div>' +
|
|
1848
|
+
(event.data ? '<div style="color:#a5c6db;margin-top:4px;">' + JSON.stringify(event.data) + '</div>' : '');
|
|
1208
1849
|
$events.prepend(item);
|
|
1209
|
-
while ($events.children.length >
|
|
1850
|
+
while ($events.children.length > 120) {
|
|
1210
1851
|
$events.removeChild($events.lastChild);
|
|
1211
1852
|
}
|
|
1212
1853
|
}
|
|
@@ -1214,9 +1855,9 @@ https://developer.android.com</textarea>
|
|
|
1214
1855
|
function renderMetrics(payload) {
|
|
1215
1856
|
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
1216
1857
|
$metrics.innerHTML = '';
|
|
1217
|
-
for (const entry of entries.slice(0,
|
|
1858
|
+
for (const entry of entries.slice(0, 25)) {
|
|
1218
1859
|
const item = document.createElement('div');
|
|
1219
|
-
item.className = '
|
|
1860
|
+
item.className = 'item';
|
|
1220
1861
|
item.innerHTML =
|
|
1221
1862
|
'<div class="meta">' + entry.name + '</div>' +
|
|
1222
1863
|
'<div>count=' + entry.count + ' success=' + entry.success + ' errors=' + entry.errors + '</div>' +
|
|
@@ -1228,6 +1869,77 @@ https://developer.android.com</textarea>
|
|
|
1228
1869
|
}
|
|
1229
1870
|
}
|
|
1230
1871
|
|
|
1872
|
+
function renderLanes(payload) {
|
|
1873
|
+
const lanes = Array.isArray(payload.lanes) ? payload.lanes : [];
|
|
1874
|
+
$lanes.innerHTML = '';
|
|
1875
|
+
for (const lane of lanes.slice(0, 30)) {
|
|
1876
|
+
const item = document.createElement('div');
|
|
1877
|
+
item.className = 'item';
|
|
1878
|
+
item.innerHTML =
|
|
1879
|
+
'<div class="meta">' + lane.id + (lane.active ? ' [active]' : '') + '</div>' +
|
|
1880
|
+
'<div>queue=' + lane.queueDepth + ' completed=' + lane.completed + ' failed=' + lane.failed + ' cancelled=' + lane.cancelled + '</div>' +
|
|
1881
|
+
'<div>device=' + (lane.deviceId || 'default') + '</div>';
|
|
1882
|
+
$lanes.appendChild(item);
|
|
1883
|
+
}
|
|
1884
|
+
if (!lanes.length) {
|
|
1885
|
+
$lanes.textContent = 'No lanes yet.';
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function renderJobs(payload) {
|
|
1890
|
+
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
|
1891
|
+
$jobs.innerHTML = '';
|
|
1892
|
+
for (const job of jobs.slice(0, 40)) {
|
|
1893
|
+
const item = document.createElement('div');
|
|
1894
|
+
item.className = 'item';
|
|
1895
|
+
item.innerHTML =
|
|
1896
|
+
'<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
|
|
1897
|
+
'<div>lane=' + (job.laneId || '-') + ' duration=' + (job.durationMs || 0) + 'ms</div>' +
|
|
1898
|
+
(job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
|
|
1899
|
+
|
|
1900
|
+
if (job.status === 'queued') {
|
|
1901
|
+
const cancelBtn = document.createElement('button');
|
|
1902
|
+
cancelBtn.className = 'w';
|
|
1903
|
+
cancelBtn.textContent = 'Cancel';
|
|
1904
|
+
cancelBtn.style.marginTop = '6px';
|
|
1905
|
+
cancelBtn.addEventListener('click', async function () {
|
|
1906
|
+
try {
|
|
1907
|
+
const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
|
|
1908
|
+
renderOutput(result);
|
|
1909
|
+
await refreshJobsAndLanes();
|
|
1910
|
+
setMessage('Job cancelled', false);
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
setMessage(String(error), true);
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
item.appendChild(cancelBtn);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (job.status === 'failed' || job.status === 'completed' || job.status === 'cancelled') {
|
|
1919
|
+
const retryBtn = document.createElement('button');
|
|
1920
|
+
retryBtn.className = 's';
|
|
1921
|
+
retryBtn.textContent = 'Retry';
|
|
1922
|
+
retryBtn.style.marginTop = '6px';
|
|
1923
|
+
retryBtn.addEventListener('click', async function () {
|
|
1924
|
+
try {
|
|
1925
|
+
const result = await api('/api/jobs/' + job.id + '/retry', 'POST', {});
|
|
1926
|
+
renderOutput(result);
|
|
1927
|
+
await refreshJobsAndLanes();
|
|
1928
|
+
setMessage('Job retried', false);
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
setMessage(String(error), true);
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
item.appendChild(retryBtn);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
$jobs.appendChild(item);
|
|
1937
|
+
}
|
|
1938
|
+
if (!jobs.length) {
|
|
1939
|
+
$jobs.textContent = 'No jobs yet.';
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1231
1943
|
async function api(path, method, body) {
|
|
1232
1944
|
const response = await fetch(path, {
|
|
1233
1945
|
method: method || 'GET',
|
|
@@ -1245,14 +1957,43 @@ https://developer.android.com</textarea>
|
|
|
1245
1957
|
return state.deviceId || undefined;
|
|
1246
1958
|
}
|
|
1247
1959
|
|
|
1248
|
-
|
|
1960
|
+
function selectedWorkflowName() {
|
|
1961
|
+
const value = ($workflowSelect.value || '').trim();
|
|
1962
|
+
return value || undefined;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function stressConfig() {
|
|
1966
|
+
return {
|
|
1967
|
+
urls: $stressUrls.value
|
|
1968
|
+
.split('\n')
|
|
1969
|
+
.map(function (line) { return line.trim(); })
|
|
1970
|
+
.filter(function (line) { return line.length > 0; }),
|
|
1971
|
+
loops: Number($stressLoops.value || '1'),
|
|
1972
|
+
waitForReadyMs: Number($stressWait.value || '1000'),
|
|
1973
|
+
includeSnapshotAfterEach: true,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
async function refreshJobsAndLanes() {
|
|
1978
|
+
const jobsPayload = await api('/api/jobs');
|
|
1979
|
+
renderJobs(jobsPayload);
|
|
1980
|
+
|
|
1981
|
+
const lanesPayload = await api('/api/lanes');
|
|
1982
|
+
renderLanes(lanesPayload);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function refreshCore() {
|
|
1249
1986
|
const statePayload = await api('/api/state');
|
|
1250
1987
|
$status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
|
|
1251
1988
|
$versionPill.textContent = 'v' + statePayload.version;
|
|
1252
1989
|
$workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
|
|
1990
|
+
$lanePill.textContent = 'lanes: ' + statePayload.laneCount;
|
|
1991
|
+
$queuePill.textContent = 'queue: ' + statePayload.queueDepth;
|
|
1253
1992
|
|
|
1254
1993
|
const devicesPayload = await api('/api/devices');
|
|
1255
1994
|
const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
|
|
1995
|
+
const previousDevice = state.deviceId;
|
|
1996
|
+
|
|
1256
1997
|
$deviceSelect.innerHTML = '';
|
|
1257
1998
|
for (const device of devices) {
|
|
1258
1999
|
const option = document.createElement('option');
|
|
@@ -1260,7 +2001,13 @@ https://developer.android.com</textarea>
|
|
|
1260
2001
|
option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
|
|
1261
2002
|
$deviceSelect.appendChild(option);
|
|
1262
2003
|
}
|
|
1263
|
-
|
|
2004
|
+
|
|
2005
|
+
if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
|
|
2006
|
+
state.deviceId = previousDevice;
|
|
2007
|
+
} else {
|
|
2008
|
+
state.deviceId = devices[0] ? devices[0].id : undefined;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1264
2011
|
if (state.deviceId) {
|
|
1265
2012
|
$deviceSelect.value = state.deviceId;
|
|
1266
2013
|
$devicePill.textContent = 'device: ' + state.deviceId;
|
|
@@ -1269,23 +2016,40 @@ https://developer.android.com</textarea>
|
|
|
1269
2016
|
}
|
|
1270
2017
|
|
|
1271
2018
|
const workflowsPayload = await api('/api/workflows');
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
2019
|
+
const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
|
|
2020
|
+
state.workflows = workflows;
|
|
2021
|
+
|
|
2022
|
+
const fillWorkflowSelect = function (selectElement) {
|
|
2023
|
+
const prev = (selectElement.value || '').trim();
|
|
2024
|
+
selectElement.innerHTML = '';
|
|
2025
|
+
for (const wf of workflows) {
|
|
2026
|
+
const option = document.createElement('option');
|
|
2027
|
+
option.value = wf.name;
|
|
2028
|
+
option.textContent = wf.name;
|
|
2029
|
+
selectElement.appendChild(option);
|
|
2030
|
+
}
|
|
2031
|
+
if (prev && workflows.some(function (w) { return w.name === prev; })) {
|
|
2032
|
+
selectElement.value = prev;
|
|
2033
|
+
} else if (workflows[0]) {
|
|
2034
|
+
selectElement.value = workflows[0].name;
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
fillWorkflowSelect($workflowSelect);
|
|
2039
|
+
fillWorkflowSelect($jobWorkflow);
|
|
2040
|
+
|
|
2041
|
+
if ($workflowSelect.value) {
|
|
2042
|
+
const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
2043
|
+
if (selected) {
|
|
2044
|
+
$workflowName.value = selected.name;
|
|
2045
|
+
$workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
|
|
2046
|
+
}
|
|
1285
2047
|
}
|
|
1286
2048
|
|
|
1287
2049
|
const metricsPayload = await api('/api/metrics');
|
|
1288
2050
|
renderMetrics(metricsPayload);
|
|
2051
|
+
|
|
2052
|
+
await refreshJobsAndLanes();
|
|
1289
2053
|
}
|
|
1290
2054
|
|
|
1291
2055
|
function connectEvents() {
|
|
@@ -1293,7 +2057,7 @@ https://developer.android.com</textarea>
|
|
|
1293
2057
|
es.onmessage = function (event) {
|
|
1294
2058
|
try {
|
|
1295
2059
|
addEvent(JSON.parse(event.data));
|
|
1296
|
-
} catch (
|
|
2060
|
+
} catch (error) {
|
|
1297
2061
|
// ignore
|
|
1298
2062
|
}
|
|
1299
2063
|
};
|
|
@@ -1303,13 +2067,23 @@ https://developer.android.com</textarea>
|
|
|
1303
2067
|
setMessage('Opening URL: ' + url, false);
|
|
1304
2068
|
const result = await api('/api/open-url', 'POST', {
|
|
1305
2069
|
deviceId: selectedDeviceId(),
|
|
1306
|
-
url
|
|
1307
|
-
waitForReadyMs:
|
|
2070
|
+
url,
|
|
2071
|
+
waitForReadyMs: 900,
|
|
1308
2072
|
});
|
|
1309
2073
|
renderOutput(result);
|
|
1310
2074
|
setMessage('URL opened', false);
|
|
1311
2075
|
}
|
|
1312
2076
|
|
|
2077
|
+
async function runSuite() {
|
|
2078
|
+
setMessage('Running suite', false);
|
|
2079
|
+
const result = await api('/api/snapshot-suite', 'POST', {
|
|
2080
|
+
deviceId: selectedDeviceId(),
|
|
2081
|
+
packageName: 'com.android.chrome',
|
|
2082
|
+
});
|
|
2083
|
+
renderOutput(result);
|
|
2084
|
+
setMessage('Suite completed', false);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
1313
2087
|
async function captureSnapshot() {
|
|
1314
2088
|
const kind = $snapshotKind.value;
|
|
1315
2089
|
setMessage('Capturing snapshot: ' + kind, false);
|
|
@@ -1328,40 +2102,43 @@ https://developer.android.com</textarea>
|
|
|
1328
2102
|
const result = await api('/api/snapshot/diff', 'POST', {
|
|
1329
2103
|
deviceId: selectedDeviceId(),
|
|
1330
2104
|
packageName: 'com.android.chrome',
|
|
1331
|
-
kind
|
|
2105
|
+
kind,
|
|
1332
2106
|
});
|
|
1333
2107
|
renderOutput(result);
|
|
1334
|
-
setMessage('Snapshot diff
|
|
2108
|
+
setMessage('Snapshot diff completed', false);
|
|
1335
2109
|
}
|
|
1336
2110
|
|
|
1337
|
-
async function
|
|
1338
|
-
setMessage('
|
|
1339
|
-
const result = await api('/api/
|
|
2111
|
+
async function captureDeviceProfile() {
|
|
2112
|
+
setMessage('Capturing device profile', false);
|
|
2113
|
+
const result = await api('/api/device/profile', 'POST', {
|
|
1340
2114
|
deviceId: selectedDeviceId(),
|
|
1341
2115
|
packageName: 'com.android.chrome',
|
|
1342
2116
|
});
|
|
1343
2117
|
renderOutput(result);
|
|
1344
|
-
setMessage('
|
|
2118
|
+
setMessage('Device profile completed', false);
|
|
1345
2119
|
}
|
|
1346
2120
|
|
|
1347
|
-
async function
|
|
1348
|
-
setMessage('
|
|
1349
|
-
const
|
|
1350
|
-
.
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
2121
|
+
async function captureAllProfiles() {
|
|
2122
|
+
setMessage('Capturing profiles for all devices', false);
|
|
2123
|
+
const result = await api('/api/device/profiles', 'POST', {
|
|
2124
|
+
packageName: 'com.android.chrome',
|
|
2125
|
+
});
|
|
2126
|
+
renderOutput(result);
|
|
2127
|
+
setMessage('Profiles matrix completed', false);
|
|
2128
|
+
}
|
|
1355
2129
|
|
|
2130
|
+
async function runStress() {
|
|
2131
|
+
setMessage('Running stress run', false);
|
|
2132
|
+
const cfg = stressConfig();
|
|
1356
2133
|
const result = await api('/api/stress-run', 'POST', {
|
|
1357
2134
|
deviceId: selectedDeviceId(),
|
|
1358
|
-
urls,
|
|
1359
|
-
loops,
|
|
1360
|
-
waitForReadyMs,
|
|
2135
|
+
urls: cfg.urls,
|
|
2136
|
+
loops: cfg.loops,
|
|
2137
|
+
waitForReadyMs: cfg.waitForReadyMs,
|
|
1361
2138
|
includeSnapshotAfterEach: true,
|
|
1362
2139
|
});
|
|
1363
2140
|
renderOutput(result);
|
|
1364
|
-
setMessage('Stress run
|
|
2141
|
+
setMessage('Stress run completed', false);
|
|
1365
2142
|
}
|
|
1366
2143
|
|
|
1367
2144
|
async function saveWorkflow() {
|
|
@@ -1373,19 +2150,16 @@ https://developer.android.com</textarea>
|
|
|
1373
2150
|
try {
|
|
1374
2151
|
steps = JSON.parse($workflowSteps.value || '[]');
|
|
1375
2152
|
} catch (error) {
|
|
1376
|
-
throw new Error('workflow steps must be valid JSON
|
|
2153
|
+
throw new Error('workflow steps must be valid JSON');
|
|
1377
2154
|
}
|
|
1378
|
-
const result = await api('/api/workflows', 'POST', {
|
|
1379
|
-
name,
|
|
1380
|
-
steps,
|
|
1381
|
-
});
|
|
2155
|
+
const result = await api('/api/workflows', 'POST', { name, steps });
|
|
1382
2156
|
renderOutput(result);
|
|
1383
2157
|
setMessage('Workflow saved', false);
|
|
1384
|
-
await
|
|
2158
|
+
await refreshCore();
|
|
1385
2159
|
}
|
|
1386
2160
|
|
|
1387
2161
|
async function runWorkflow() {
|
|
1388
|
-
const name = (
|
|
2162
|
+
const name = selectedWorkflowName();
|
|
1389
2163
|
if (!name) {
|
|
1390
2164
|
throw new Error('select workflow first');
|
|
1391
2165
|
}
|
|
@@ -1396,18 +2170,113 @@ https://developer.android.com</textarea>
|
|
|
1396
2170
|
includeRaw: false,
|
|
1397
2171
|
});
|
|
1398
2172
|
renderOutput(result);
|
|
1399
|
-
setMessage('Workflow
|
|
2173
|
+
setMessage('Workflow executed', false);
|
|
1400
2174
|
}
|
|
1401
2175
|
|
|
1402
2176
|
async function deleteWorkflow() {
|
|
1403
|
-
const name = (
|
|
2177
|
+
const name = selectedWorkflowName();
|
|
1404
2178
|
if (!name) {
|
|
1405
2179
|
throw new Error('select workflow first');
|
|
1406
2180
|
}
|
|
1407
2181
|
const result = await api('/api/workflows/' + encodeURIComponent(name), 'DELETE');
|
|
1408
2182
|
renderOutput(result);
|
|
1409
2183
|
setMessage('Workflow deleted', false);
|
|
1410
|
-
await
|
|
2184
|
+
await refreshCore();
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
async function exportWorkflows() {
|
|
2188
|
+
const result = await api('/api/workflows/export');
|
|
2189
|
+
$workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
|
|
2190
|
+
renderOutput(result);
|
|
2191
|
+
setMessage('Workflows exported to editor', false);
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
async function importWorkflows() {
|
|
2195
|
+
let parsed;
|
|
2196
|
+
try {
|
|
2197
|
+
parsed = JSON.parse($workflowSteps.value || '{}');
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
throw new Error('workflow editor must contain valid JSON');
|
|
2200
|
+
}
|
|
2201
|
+
const result = await api('/api/workflows/import', 'POST', {
|
|
2202
|
+
workflows: parsed,
|
|
2203
|
+
replace: false,
|
|
2204
|
+
});
|
|
2205
|
+
renderOutput(result);
|
|
2206
|
+
setMessage('Workflows imported', false);
|
|
2207
|
+
await refreshCore();
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
async function enqueueSingleJob() {
|
|
2211
|
+
const type = ($jobType.value || '').trim();
|
|
2212
|
+
const input = { deviceId: selectedDeviceId() };
|
|
2213
|
+
|
|
2214
|
+
if (type === 'open_url') {
|
|
2215
|
+
input.url = $jobUrl.value || 'https://developer.android.com';
|
|
2216
|
+
input.waitForReadyMs = 900;
|
|
2217
|
+
} else if (type === 'snapshot_suite') {
|
|
2218
|
+
input.packageName = 'com.android.chrome';
|
|
2219
|
+
} else if (type === 'stress_run') {
|
|
2220
|
+
const cfg = stressConfig();
|
|
2221
|
+
input.urls = cfg.urls;
|
|
2222
|
+
input.loops = cfg.loops;
|
|
2223
|
+
input.waitForReadyMs = cfg.waitForReadyMs;
|
|
2224
|
+
input.includeSnapshotAfterEach = true;
|
|
2225
|
+
} else if (type === 'workflow_run') {
|
|
2226
|
+
const workflowName = ($jobWorkflow.value || '').trim();
|
|
2227
|
+
if (!workflowName) {
|
|
2228
|
+
throw new Error('select workflow for workflow_run job');
|
|
2229
|
+
}
|
|
2230
|
+
input.name = workflowName;
|
|
2231
|
+
input.packageName = 'com.android.chrome';
|
|
2232
|
+
} else if (type === 'device_profile') {
|
|
2233
|
+
input.packageName = 'com.android.chrome';
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
const result = await api('/api/jobs', 'POST', { type, input });
|
|
2237
|
+
renderOutput(result);
|
|
2238
|
+
setMessage('Job enqueued', false);
|
|
2239
|
+
await refreshJobsAndLanes();
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
async function enqueueBulkJobs() {
|
|
2243
|
+
let parsed;
|
|
2244
|
+
try {
|
|
2245
|
+
parsed = JSON.parse($bulkJobs.value || '[]');
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
throw new Error('bulk jobs must be valid JSON array');
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
if (!Array.isArray(parsed)) {
|
|
2251
|
+
throw new Error('bulk jobs must be array');
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
for (const item of parsed) {
|
|
2255
|
+
if (!item.input || typeof item.input !== 'object' || Array.isArray(item.input)) {
|
|
2256
|
+
item.input = {};
|
|
2257
|
+
}
|
|
2258
|
+
if (!item.input.deviceId) {
|
|
2259
|
+
item.input.deviceId = selectedDeviceId();
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
const result = await api('/api/jobs/bulk', 'POST', { jobs: parsed });
|
|
2264
|
+
renderOutput(result);
|
|
2265
|
+
setMessage('Bulk jobs enqueued', false);
|
|
2266
|
+
await refreshJobsAndLanes();
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
async function exportSession() {
|
|
2270
|
+
const result = await api('/api/session/export');
|
|
2271
|
+
renderOutput(result);
|
|
2272
|
+
setMessage('Session exported to output', false);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
async function resetSession() {
|
|
2276
|
+
const result = await api('/api/session/reset', 'POST', { keepWorkflows: true });
|
|
2277
|
+
renderOutput(result);
|
|
2278
|
+
setMessage('Session reset complete', false);
|
|
2279
|
+
await refreshCore();
|
|
1411
2280
|
}
|
|
1412
2281
|
|
|
1413
2282
|
document.getElementById('open-url-btn').addEventListener('click', async function () {
|
|
@@ -1422,6 +2291,12 @@ https://developer.android.com</textarea>
|
|
|
1422
2291
|
document.getElementById('snapshot-diff-btn').addEventListener('click', async function () {
|
|
1423
2292
|
try { await captureSnapshotDiff(); } catch (error) { setMessage(String(error), true); }
|
|
1424
2293
|
});
|
|
2294
|
+
document.getElementById('profile-btn').addEventListener('click', async function () {
|
|
2295
|
+
try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
|
|
2296
|
+
});
|
|
2297
|
+
document.getElementById('profiles-btn').addEventListener('click', async function () {
|
|
2298
|
+
try { await captureAllProfiles(); } catch (error) { setMessage(String(error), true); }
|
|
2299
|
+
});
|
|
1425
2300
|
document.getElementById('stress-btn').addEventListener('click', async function () {
|
|
1426
2301
|
try { await runStress(); } catch (error) { setMessage(String(error), true); }
|
|
1427
2302
|
});
|
|
@@ -1434,20 +2309,36 @@ https://developer.android.com</textarea>
|
|
|
1434
2309
|
document.getElementById('workflow-delete-btn').addEventListener('click', async function () {
|
|
1435
2310
|
try { await deleteWorkflow(); } catch (error) { setMessage(String(error), true); }
|
|
1436
2311
|
});
|
|
2312
|
+
document.getElementById('workflow-export-btn').addEventListener('click', async function () {
|
|
2313
|
+
try { await exportWorkflows(); } catch (error) { setMessage(String(error), true); }
|
|
2314
|
+
});
|
|
2315
|
+
document.getElementById('workflow-import-btn').addEventListener('click', async function () {
|
|
2316
|
+
try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
|
|
2317
|
+
});
|
|
2318
|
+
document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
|
|
2319
|
+
try { await enqueueSingleJob(); } catch (error) { setMessage(String(error), true); }
|
|
2320
|
+
});
|
|
2321
|
+
document.getElementById('job-bulk-btn').addEventListener('click', async function () {
|
|
2322
|
+
try { await enqueueBulkJobs(); } catch (error) { setMessage(String(error), true); }
|
|
2323
|
+
});
|
|
2324
|
+
document.getElementById('job-refresh-btn').addEventListener('click', async function () {
|
|
2325
|
+
try { await refreshJobsAndLanes(); setMessage('Jobs and lanes refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
2326
|
+
});
|
|
1437
2327
|
document.getElementById('refresh-state-btn').addEventListener('click', async function () {
|
|
1438
|
-
try {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
} catch (error) {
|
|
1442
|
-
|
|
1443
|
-
|
|
2328
|
+
try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
2329
|
+
});
|
|
2330
|
+
document.getElementById('export-session-btn').addEventListener('click', async function () {
|
|
2331
|
+
try { await exportSession(); } catch (error) { setMessage(String(error), true); }
|
|
2332
|
+
});
|
|
2333
|
+
document.getElementById('reset-session-btn').addEventListener('click', async function () {
|
|
2334
|
+
try { await resetSession(); } catch (error) { setMessage(String(error), true); }
|
|
1444
2335
|
});
|
|
1445
2336
|
document.getElementById('clear-output-btn').addEventListener('click', function () {
|
|
1446
2337
|
renderOutput({});
|
|
1447
2338
|
});
|
|
1448
2339
|
|
|
1449
2340
|
$deviceSelect.addEventListener('change', function () {
|
|
1450
|
-
state.deviceId = $deviceSelect.value;
|
|
2341
|
+
state.deviceId = $deviceSelect.value || undefined;
|
|
1451
2342
|
$devicePill.textContent = 'device: ' + (state.deviceId || 'none');
|
|
1452
2343
|
});
|
|
1453
2344
|
|
|
@@ -1466,17 +2357,98 @@ https://developer.android.com</textarea>
|
|
|
1466
2357
|
}
|
|
1467
2358
|
|
|
1468
2359
|
$workflowSelect.addEventListener('change', function () {
|
|
1469
|
-
const selected = state.workflows.find(function (
|
|
2360
|
+
const selected = state.workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
1470
2361
|
if (selected) {
|
|
1471
2362
|
$workflowName.value = selected.name;
|
|
1472
2363
|
$workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
|
|
1473
2364
|
}
|
|
1474
2365
|
});
|
|
1475
2366
|
|
|
2367
|
+
function connectEvents() {
|
|
2368
|
+
const es = new EventSource('/api/events');
|
|
2369
|
+
es.onmessage = function (event) {
|
|
2370
|
+
try {
|
|
2371
|
+
addEvent(JSON.parse(event.data));
|
|
2372
|
+
} catch {
|
|
2373
|
+
// ignore
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
async function refreshCore() {
|
|
2379
|
+
const statePayload = await api('/api/state');
|
|
2380
|
+
$status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
|
|
2381
|
+
$versionPill.textContent = 'v' + statePayload.version;
|
|
2382
|
+
$workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
|
|
2383
|
+
$lanePill.textContent = 'lanes: ' + statePayload.laneCount;
|
|
2384
|
+
$queuePill.textContent = 'queue: ' + statePayload.queueDepth;
|
|
2385
|
+
|
|
2386
|
+
const devicesPayload = await api('/api/devices');
|
|
2387
|
+
const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
|
|
2388
|
+
const previousDevice = state.deviceId;
|
|
2389
|
+
|
|
2390
|
+
$deviceSelect.innerHTML = '';
|
|
2391
|
+
for (const device of devices) {
|
|
2392
|
+
const option = document.createElement('option');
|
|
2393
|
+
option.value = device.id;
|
|
2394
|
+
option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
|
|
2395
|
+
$deviceSelect.appendChild(option);
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
|
|
2399
|
+
state.deviceId = previousDevice;
|
|
2400
|
+
} else {
|
|
2401
|
+
state.deviceId = devices[0] ? devices[0].id : undefined;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
if (state.deviceId) {
|
|
2405
|
+
$deviceSelect.value = state.deviceId;
|
|
2406
|
+
$devicePill.textContent = 'device: ' + state.deviceId;
|
|
2407
|
+
} else {
|
|
2408
|
+
$devicePill.textContent = 'device: none';
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const workflowsPayload = await api('/api/workflows');
|
|
2412
|
+
const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
|
|
2413
|
+
state.workflows = workflows;
|
|
2414
|
+
|
|
2415
|
+
const fillSelect = function (selectElement) {
|
|
2416
|
+
const prev = (selectElement.value || '').trim();
|
|
2417
|
+
selectElement.innerHTML = '';
|
|
2418
|
+
for (const workflow of workflows) {
|
|
2419
|
+
const option = document.createElement('option');
|
|
2420
|
+
option.value = workflow.name;
|
|
2421
|
+
option.textContent = workflow.name;
|
|
2422
|
+
selectElement.appendChild(option);
|
|
2423
|
+
}
|
|
2424
|
+
if (prev && workflows.some(function (w) { return w.name === prev; })) {
|
|
2425
|
+
selectElement.value = prev;
|
|
2426
|
+
} else if (workflows[0]) {
|
|
2427
|
+
selectElement.value = workflows[0].name;
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
fillSelect($workflowSelect);
|
|
2432
|
+
fillSelect($jobWorkflow);
|
|
2433
|
+
|
|
2434
|
+
if ($workflowSelect.value) {
|
|
2435
|
+
const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
2436
|
+
if (selected) {
|
|
2437
|
+
$workflowName.value = selected.name;
|
|
2438
|
+
$workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
const metricsPayload = await api('/api/metrics');
|
|
2443
|
+
renderMetrics(metricsPayload);
|
|
2444
|
+
|
|
2445
|
+
await refreshJobsAndLanes();
|
|
2446
|
+
}
|
|
2447
|
+
|
|
1476
2448
|
async function init() {
|
|
1477
2449
|
try {
|
|
1478
|
-
await
|
|
1479
|
-
const history = await api('/api/history?limit=
|
|
2450
|
+
await refreshCore();
|
|
2451
|
+
const history = await api('/api/history?limit=45');
|
|
1480
2452
|
const events = Array.isArray(history.events) ? history.events : [];
|
|
1481
2453
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1482
2454
|
addEvent(events[i]);
|
|
@@ -1484,12 +2456,14 @@ https://developer.android.com</textarea>
|
|
|
1484
2456
|
connectEvents();
|
|
1485
2457
|
renderOutput({ ok: true, message: 'UI ready', updateHint: '${UPDATE_HINT}' });
|
|
1486
2458
|
setMessage('Ready.', false);
|
|
2459
|
+
|
|
1487
2460
|
setInterval(async function () {
|
|
1488
2461
|
try {
|
|
1489
2462
|
const metricsPayload = await api('/api/metrics');
|
|
1490
2463
|
renderMetrics(metricsPayload);
|
|
2464
|
+
await refreshJobsAndLanes();
|
|
1491
2465
|
} catch (error) {
|
|
1492
|
-
setMessage('
|
|
2466
|
+
setMessage('Background refresh failed', true);
|
|
1493
2467
|
}
|
|
1494
2468
|
}, 5000);
|
|
1495
2469
|
} catch (error) {
|