the-android-mcp 3.3.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 +699 -259
- package/dist/web-ui.js.map +1 -1
- package/package.json +1 -1
package/dist/web-ui.js
CHANGED
|
@@ -19,10 +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 MAX_JOBS =
|
|
24
|
-
const
|
|
25
|
-
const WORKFLOW_FILE = path_1.default.join(
|
|
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');
|
|
26
27
|
const SNAPSHOT_KINDS = [
|
|
27
28
|
'radio',
|
|
28
29
|
'display',
|
|
@@ -39,16 +40,33 @@ const metrics = {};
|
|
|
39
40
|
const snapshotCache = {};
|
|
40
41
|
let workflows = loadWorkflows();
|
|
41
42
|
const jobs = [];
|
|
42
|
-
const
|
|
43
|
-
let jobRunnerActive = false;
|
|
43
|
+
const lanes = {};
|
|
44
44
|
function nowIso() {
|
|
45
45
|
return new Date().toISOString();
|
|
46
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
|
+
}
|
|
47
61
|
function isSnapshotKind(value) {
|
|
48
62
|
return SNAPSHOT_KINDS.includes(value);
|
|
49
63
|
}
|
|
50
64
|
function isJobType(value) {
|
|
51
|
-
return value === 'open_url' ||
|
|
65
|
+
return (value === 'open_url' ||
|
|
66
|
+
value === 'snapshot_suite' ||
|
|
67
|
+
value === 'stress_run' ||
|
|
68
|
+
value === 'workflow_run' ||
|
|
69
|
+
value === 'device_profile');
|
|
52
70
|
}
|
|
53
71
|
function clampInt(value, fallback, min, max) {
|
|
54
72
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
@@ -69,6 +87,7 @@ function pushEvent(type, message, data) {
|
|
|
69
87
|
if (eventHistory.length > MAX_EVENTS) {
|
|
70
88
|
eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
|
|
71
89
|
}
|
|
90
|
+
appendSessionEvent(event);
|
|
72
91
|
broadcastEvent(event);
|
|
73
92
|
return event;
|
|
74
93
|
}
|
|
@@ -89,6 +108,16 @@ function broadcastEvent(event) {
|
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
110
|
}
|
|
111
|
+
function sendJson(response, statusCode, body) {
|
|
112
|
+
response.statusCode = statusCode;
|
|
113
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
114
|
+
response.end(JSON.stringify(body));
|
|
115
|
+
}
|
|
116
|
+
function sendHtml(response, html) {
|
|
117
|
+
response.statusCode = 200;
|
|
118
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
119
|
+
response.end(html);
|
|
120
|
+
}
|
|
92
121
|
function trackMetric(name, durationMs, ok, errorMessage) {
|
|
93
122
|
const entry = metrics[name] ?? {
|
|
94
123
|
name,
|
|
@@ -124,16 +153,6 @@ async function withMetric(name, fn) {
|
|
|
124
153
|
throw error;
|
|
125
154
|
}
|
|
126
155
|
}
|
|
127
|
-
function sendJson(response, statusCode, body) {
|
|
128
|
-
response.statusCode = statusCode;
|
|
129
|
-
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
130
|
-
response.end(JSON.stringify(body));
|
|
131
|
-
}
|
|
132
|
-
function sendHtml(response, html) {
|
|
133
|
-
response.statusCode = 200;
|
|
134
|
-
response.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
135
|
-
response.end(html);
|
|
136
|
-
}
|
|
137
156
|
function compactStringSummary(value) {
|
|
138
157
|
if (typeof value !== 'string') {
|
|
139
158
|
return { type: typeof value };
|
|
@@ -141,7 +160,7 @@ function compactStringSummary(value) {
|
|
|
141
160
|
return {
|
|
142
161
|
length: value.length,
|
|
143
162
|
lines: value.split('\n').length,
|
|
144
|
-
preview: value.slice(0,
|
|
163
|
+
preview: value.slice(0, 220),
|
|
145
164
|
};
|
|
146
165
|
}
|
|
147
166
|
function summarizeObjectShape(input) {
|
|
@@ -165,7 +184,7 @@ function summarizeObjectShape(input) {
|
|
|
165
184
|
result[key] = { type: 'array', length: value.length };
|
|
166
185
|
continue;
|
|
167
186
|
}
|
|
168
|
-
if (typeof value === 'object'
|
|
187
|
+
if (typeof value === 'object') {
|
|
169
188
|
result[key] = { type: 'object', keys: Object.keys(value).length };
|
|
170
189
|
continue;
|
|
171
190
|
}
|
|
@@ -173,19 +192,41 @@ function summarizeObjectShape(input) {
|
|
|
173
192
|
}
|
|
174
193
|
return result;
|
|
175
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
|
+
}
|
|
176
217
|
function normalizeWorkflowStep(value) {
|
|
177
218
|
if (!value || typeof value !== 'object') {
|
|
178
|
-
throw new Error('
|
|
219
|
+
throw new Error('Workflow step must be an object');
|
|
179
220
|
}
|
|
180
221
|
const step = value;
|
|
181
222
|
const type = step.type;
|
|
182
223
|
if (type !== 'open_url' && type !== 'snapshot' && type !== 'snapshot_suite' && type !== 'sleep_ms') {
|
|
183
|
-
throw new Error('
|
|
224
|
+
throw new Error('Unsupported workflow step type');
|
|
184
225
|
}
|
|
185
226
|
const normalized = { type };
|
|
186
227
|
if (type === 'open_url') {
|
|
187
228
|
if (typeof step.url !== 'string' || !/^https?:\/\//i.test(step.url)) {
|
|
188
|
-
throw new Error('
|
|
229
|
+
throw new Error('open_url step requires valid url');
|
|
189
230
|
}
|
|
190
231
|
normalized.url = step.url;
|
|
191
232
|
normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
|
|
@@ -193,7 +234,7 @@ function normalizeWorkflowStep(value) {
|
|
|
193
234
|
}
|
|
194
235
|
if (type === 'snapshot') {
|
|
195
236
|
if (typeof step.snapshot !== 'string' || !isSnapshotKind(step.snapshot)) {
|
|
196
|
-
throw new Error('
|
|
237
|
+
throw new Error('snapshot step requires valid snapshot kind');
|
|
197
238
|
}
|
|
198
239
|
normalized.snapshot = step.snapshot;
|
|
199
240
|
if (typeof step.packageName === 'string') {
|
|
@@ -208,40 +249,12 @@ function normalizeWorkflowStep(value) {
|
|
|
208
249
|
}
|
|
209
250
|
return normalized;
|
|
210
251
|
}
|
|
211
|
-
normalized.durationMs = clampInt(step.durationMs, 500, 50,
|
|
252
|
+
normalized.durationMs = clampInt(step.durationMs, 500, 50, 30000);
|
|
212
253
|
return normalized;
|
|
213
254
|
}
|
|
214
|
-
function ensureWorkflowStore() {
|
|
215
|
-
if (!fs_1.default.existsSync(WORKFLOW_DIR)) {
|
|
216
|
-
fs_1.default.mkdirSync(WORKFLOW_DIR, { recursive: true });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
function defaultWorkflows() {
|
|
220
|
-
const createdAt = nowIso();
|
|
221
|
-
return {
|
|
222
|
-
'smoke-web-flow': {
|
|
223
|
-
name: 'smoke-web-flow',
|
|
224
|
-
description: 'Open URLs and collect quick snapshots.',
|
|
225
|
-
updatedAt: createdAt,
|
|
226
|
-
steps: [
|
|
227
|
-
{ type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 1000 },
|
|
228
|
-
{ type: 'snapshot', snapshot: 'radio' },
|
|
229
|
-
{ type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 1000 },
|
|
230
|
-
{ type: 'snapshot', snapshot: 'display' },
|
|
231
|
-
{ type: 'open_url', url: 'https://developer.android.com', waitForReadyMs: 1000 },
|
|
232
|
-
],
|
|
233
|
-
},
|
|
234
|
-
'diagnostic-suite': {
|
|
235
|
-
name: 'diagnostic-suite',
|
|
236
|
-
description: 'Run complete v3 suite.',
|
|
237
|
-
updatedAt: createdAt,
|
|
238
|
-
steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
|
|
239
|
-
},
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
255
|
function loadWorkflows() {
|
|
243
256
|
try {
|
|
244
|
-
|
|
257
|
+
ensureAppStateDir();
|
|
245
258
|
if (!fs_1.default.existsSync(WORKFLOW_FILE)) {
|
|
246
259
|
const defaults = defaultWorkflows();
|
|
247
260
|
fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(defaults, null, 2), 'utf8');
|
|
@@ -261,11 +274,11 @@ function loadWorkflows() {
|
|
|
261
274
|
if (typeof item.name !== 'string' || item.name.trim().length === 0) {
|
|
262
275
|
continue;
|
|
263
276
|
}
|
|
264
|
-
const
|
|
277
|
+
const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
|
|
265
278
|
const steps = [];
|
|
266
|
-
for (const
|
|
279
|
+
for (const stepRaw of stepsRaw) {
|
|
267
280
|
try {
|
|
268
|
-
steps.push(normalizeWorkflowStep(
|
|
281
|
+
steps.push(normalizeWorkflowStep(stepRaw));
|
|
269
282
|
}
|
|
270
283
|
catch {
|
|
271
284
|
// skip invalid step
|
|
@@ -281,29 +294,26 @@ function loadWorkflows() {
|
|
|
281
294
|
steps,
|
|
282
295
|
};
|
|
283
296
|
}
|
|
284
|
-
|
|
285
|
-
return defaultWorkflows();
|
|
286
|
-
}
|
|
287
|
-
return result;
|
|
297
|
+
return Object.keys(result).length > 0 ? result : defaultWorkflows();
|
|
288
298
|
}
|
|
289
299
|
catch {
|
|
290
300
|
return defaultWorkflows();
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
function saveWorkflows() {
|
|
294
|
-
|
|
304
|
+
ensureAppStateDir();
|
|
295
305
|
fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(workflows, null, 2), 'utf8');
|
|
296
306
|
}
|
|
297
307
|
function workflowsList() {
|
|
298
308
|
return Object.values(workflows).sort((a, b) => a.name.localeCompare(b.name));
|
|
299
309
|
}
|
|
300
|
-
async function sleepMs(durationMs) {
|
|
301
|
-
await new Promise(resolve => setTimeout(resolve, durationMs));
|
|
302
|
-
}
|
|
303
310
|
function extractDeviceId(body) {
|
|
304
311
|
const value = body.deviceId;
|
|
305
312
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
306
313
|
}
|
|
314
|
+
async function sleepMs(durationMs) {
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, durationMs));
|
|
316
|
+
}
|
|
307
317
|
function captureSnapshot(kind, options) {
|
|
308
318
|
const includeRaw = options.includeRaw === true;
|
|
309
319
|
if (kind === 'radio') {
|
|
@@ -387,27 +397,15 @@ function diffSnapshot(prev, next) {
|
|
|
387
397
|
for (const key of keys) {
|
|
388
398
|
const oldValue = a[key];
|
|
389
399
|
const newValue = b[key];
|
|
390
|
-
if (typeof oldValue === 'string' || typeof newValue === 'string') {
|
|
391
|
-
const oldText = typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue);
|
|
392
|
-
const newText = typeof newValue === 'string' ? newValue : JSON.stringify(newValue);
|
|
393
|
-
if (oldText !== newText) {
|
|
394
|
-
changed.push({
|
|
395
|
-
key,
|
|
396
|
-
type: 'text',
|
|
397
|
-
beforeLength: oldText.length,
|
|
398
|
-
afterLength: newText.length,
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
400
|
const oldJson = JSON.stringify(oldValue);
|
|
404
401
|
const newJson = JSON.stringify(newValue);
|
|
405
402
|
if (oldJson !== newJson) {
|
|
406
403
|
changed.push({
|
|
407
404
|
key,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
405
|
+
beforeType: typeof oldValue,
|
|
406
|
+
afterType: typeof newValue,
|
|
407
|
+
beforeSize: oldJson ? oldJson.length : 0,
|
|
408
|
+
afterSize: newJson ? newJson.length : 0,
|
|
411
409
|
});
|
|
412
410
|
}
|
|
413
411
|
}
|
|
@@ -435,6 +433,22 @@ function getSnapshotSuite(deviceId, packageName) {
|
|
|
435
433
|
},
|
|
436
434
|
};
|
|
437
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
|
+
}
|
|
438
452
|
function buildDeviceProfile(deviceId, packageName) {
|
|
439
453
|
const startedAt = Date.now();
|
|
440
454
|
const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
|
|
@@ -473,7 +487,7 @@ function buildDeviceProfile(deviceId, packageName) {
|
|
|
473
487
|
includeFeatures: false,
|
|
474
488
|
packageListLines: 600,
|
|
475
489
|
});
|
|
476
|
-
|
|
490
|
+
const profile = {
|
|
477
491
|
capturedAt: nowIso(),
|
|
478
492
|
durationMs: Date.now() - startedAt,
|
|
479
493
|
deviceId: radio.deviceId,
|
|
@@ -504,6 +518,28 @@ function buildDeviceProfile(deviceId, packageName) {
|
|
|
504
518
|
disabled: packages.disabledCount,
|
|
505
519
|
},
|
|
506
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
|
+
};
|
|
507
543
|
}
|
|
508
544
|
function runStressScenario(body) {
|
|
509
545
|
const deviceId = extractDeviceId(body);
|
|
@@ -514,8 +550,8 @@ function runStressScenario(body) {
|
|
|
514
550
|
const normalizedUrls = urls.length > 0
|
|
515
551
|
? urls
|
|
516
552
|
: ['https://www.wikipedia.org', 'https://news.ycombinator.com', 'https://developer.android.com'];
|
|
517
|
-
const loops = clampInt(body.loops, 1, 1,
|
|
518
|
-
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);
|
|
519
555
|
const includeSnapshotAfterEach = body.includeSnapshotAfterEach !== false;
|
|
520
556
|
const startedAt = Date.now();
|
|
521
557
|
const steps = [];
|
|
@@ -591,7 +627,6 @@ async function runWorkflow(name, options) {
|
|
|
591
627
|
step: 'open_url',
|
|
592
628
|
url: step.url,
|
|
593
629
|
strategy: result.strategy,
|
|
594
|
-
deviceId: result.deviceId,
|
|
595
630
|
durationMs: Date.now() - stepStarted,
|
|
596
631
|
});
|
|
597
632
|
continue;
|
|
@@ -620,12 +655,9 @@ async function runWorkflow(name, options) {
|
|
|
620
655
|
});
|
|
621
656
|
continue;
|
|
622
657
|
}
|
|
623
|
-
const duration = clampInt(step.durationMs, 500, 50,
|
|
658
|
+
const duration = clampInt(step.durationMs, 500, 50, 30000);
|
|
624
659
|
await sleepMs(duration);
|
|
625
|
-
outputs.push({
|
|
626
|
-
step: 'sleep_ms',
|
|
627
|
-
durationMs: duration,
|
|
628
|
-
});
|
|
660
|
+
outputs.push({ step: 'sleep_ms', durationMs: duration });
|
|
629
661
|
}
|
|
630
662
|
return {
|
|
631
663
|
ok: true,
|
|
@@ -640,10 +672,30 @@ async function runWorkflow(name, options) {
|
|
|
640
672
|
updateHint: UPDATE_HINT,
|
|
641
673
|
};
|
|
642
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
|
+
}
|
|
643
691
|
function createJob(type, input) {
|
|
692
|
+
const rawDeviceId = typeof input.deviceId === 'string' ? input.deviceId : undefined;
|
|
693
|
+
const lane = resolveLaneForDevice(rawDeviceId);
|
|
644
694
|
const job = {
|
|
645
695
|
id: jobSeq++,
|
|
646
696
|
type,
|
|
697
|
+
laneId: lane.id,
|
|
698
|
+
deviceId: lane.deviceId,
|
|
647
699
|
status: 'queued',
|
|
648
700
|
createdAt: nowIso(),
|
|
649
701
|
input,
|
|
@@ -652,67 +704,112 @@ function createJob(type, input) {
|
|
|
652
704
|
if (jobs.length > MAX_JOBS) {
|
|
653
705
|
jobs.splice(MAX_JOBS);
|
|
654
706
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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();
|
|
658
716
|
return job;
|
|
659
717
|
}
|
|
660
718
|
function getJobById(id) {
|
|
661
719
|
return jobs.find(job => job.id === id);
|
|
662
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
|
+
}
|
|
663
738
|
function cancelQueuedJob(id) {
|
|
664
739
|
const job = getJobById(id);
|
|
665
|
-
if (!job) {
|
|
740
|
+
if (!job || job.status !== 'queued') {
|
|
666
741
|
return false;
|
|
667
742
|
}
|
|
668
|
-
|
|
743
|
+
const lane = lanes[job.laneId];
|
|
744
|
+
if (!lane) {
|
|
669
745
|
return false;
|
|
670
746
|
}
|
|
747
|
+
const idx = lane.queue.indexOf(id);
|
|
748
|
+
if (idx >= 0) {
|
|
749
|
+
lane.queue.splice(idx, 1);
|
|
750
|
+
}
|
|
671
751
|
job.status = 'cancelled';
|
|
672
752
|
job.finishedAt = nowIso();
|
|
673
753
|
job.durationMs = 0;
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
pushEvent('job-cancelled', 'Job cancelled', { id });
|
|
754
|
+
lane.cancelled += 1;
|
|
755
|
+
lane.updatedAt = nowIso();
|
|
756
|
+
pushEvent('job-cancelled', 'Job cancelled', { id, laneId: lane.id });
|
|
679
757
|
return true;
|
|
680
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
|
+
}
|
|
681
770
|
async function executeJob(job) {
|
|
682
771
|
if (job.type === 'open_url') {
|
|
683
772
|
const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
|
|
684
773
|
if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
|
|
685
|
-
throw new Error('
|
|
774
|
+
throw new Error('open_url job requires valid url');
|
|
686
775
|
}
|
|
687
776
|
const waitForReadyMs = clampInt(job.input.waitForReadyMs, 1000, 200, 10000);
|
|
688
|
-
return (0, adb_js_1.openUrlInChrome)(urlValue,
|
|
777
|
+
return (0, adb_js_1.openUrlInChrome)(urlValue, job.deviceId, {
|
|
689
778
|
waitForReadyMs,
|
|
690
779
|
fallbackToDefault: true,
|
|
691
780
|
});
|
|
692
781
|
}
|
|
693
782
|
if (job.type === 'snapshot_suite') {
|
|
694
|
-
return getSnapshotSuite(
|
|
783
|
+
return getSnapshotSuite(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
|
|
695
784
|
}
|
|
696
785
|
if (job.type === 'stress_run') {
|
|
697
|
-
|
|
786
|
+
const input = { ...job.input };
|
|
787
|
+
if (!input.deviceId && job.deviceId) {
|
|
788
|
+
input.deviceId = job.deviceId;
|
|
789
|
+
}
|
|
790
|
+
return runStressScenario(input);
|
|
698
791
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
+
});
|
|
702
802
|
}
|
|
703
|
-
return
|
|
704
|
-
deviceId: typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined,
|
|
705
|
-
packageName: typeof job.input.packageName === 'string' ? job.input.packageName : undefined,
|
|
706
|
-
includeRaw: job.input.includeRaw === true,
|
|
707
|
-
});
|
|
803
|
+
return buildDeviceProfile(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
|
|
708
804
|
}
|
|
709
|
-
async function
|
|
710
|
-
if (
|
|
805
|
+
async function processLane(lane) {
|
|
806
|
+
if (lane.active) {
|
|
711
807
|
return;
|
|
712
808
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
809
|
+
lane.active = true;
|
|
810
|
+
lane.updatedAt = nowIso();
|
|
811
|
+
while (lane.queue.length > 0) {
|
|
812
|
+
const jobId = lane.queue.shift();
|
|
716
813
|
if (!jobId) {
|
|
717
814
|
continue;
|
|
718
815
|
}
|
|
@@ -722,7 +819,12 @@ async function runJobQueue() {
|
|
|
722
819
|
}
|
|
723
820
|
job.status = 'running';
|
|
724
821
|
job.startedAt = nowIso();
|
|
725
|
-
|
|
822
|
+
lane.updatedAt = nowIso();
|
|
823
|
+
pushEvent('job-running', 'Job started', {
|
|
824
|
+
id: job.id,
|
|
825
|
+
type: job.type,
|
|
826
|
+
laneId: lane.id,
|
|
827
|
+
});
|
|
726
828
|
const startedAtMs = Date.now();
|
|
727
829
|
try {
|
|
728
830
|
const result = await executeJob(job);
|
|
@@ -730,9 +832,12 @@ async function runJobQueue() {
|
|
|
730
832
|
job.status = 'completed';
|
|
731
833
|
job.finishedAt = nowIso();
|
|
732
834
|
job.durationMs = Date.now() - startedAtMs;
|
|
835
|
+
lane.completed += 1;
|
|
836
|
+
lane.updatedAt = nowIso();
|
|
733
837
|
pushEvent('job-completed', 'Job completed', {
|
|
734
838
|
id: job.id,
|
|
735
839
|
type: job.type,
|
|
840
|
+
laneId: lane.id,
|
|
736
841
|
durationMs: job.durationMs,
|
|
737
842
|
});
|
|
738
843
|
}
|
|
@@ -742,30 +847,70 @@ async function runJobQueue() {
|
|
|
742
847
|
job.error = message;
|
|
743
848
|
job.finishedAt = nowIso();
|
|
744
849
|
job.durationMs = Date.now() - startedAtMs;
|
|
850
|
+
lane.failed += 1;
|
|
851
|
+
lane.updatedAt = nowIso();
|
|
745
852
|
pushEvent('job-failed', 'Job failed', {
|
|
746
853
|
id: job.id,
|
|
747
854
|
type: job.type,
|
|
855
|
+
laneId: lane.id,
|
|
748
856
|
error: message,
|
|
749
857
|
});
|
|
750
858
|
}
|
|
751
859
|
}
|
|
752
|
-
|
|
860
|
+
lane.active = false;
|
|
861
|
+
lane.updatedAt = nowIso();
|
|
753
862
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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,
|
|
767
894
|
}));
|
|
768
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
|
+
}
|
|
769
914
|
async function readJsonBody(request) {
|
|
770
915
|
return await new Promise((resolve, reject) => {
|
|
771
916
|
const chunks = [];
|
|
@@ -857,7 +1002,8 @@ function buildStatePayload(host, port) {
|
|
|
857
1002
|
connectedDeviceCount: devices.length,
|
|
858
1003
|
eventCount: eventHistory.length,
|
|
859
1004
|
workflowCount: Object.keys(workflows).length,
|
|
860
|
-
|
|
1005
|
+
laneCount: Object.keys(lanes).length,
|
|
1006
|
+
queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
|
|
861
1007
|
jobCount: jobs.length,
|
|
862
1008
|
snapshotKinds: SNAPSHOT_KINDS,
|
|
863
1009
|
};
|
|
@@ -878,6 +1024,23 @@ function buildMetricsPayload() {
|
|
|
878
1024
|
entries,
|
|
879
1025
|
};
|
|
880
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,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
881
1044
|
async function handleApi(request, response, context) {
|
|
882
1045
|
const method = request.method ?? 'GET';
|
|
883
1046
|
const url = request.url ? new URL(request.url, 'http://localhost') : new URL('http://localhost/');
|
|
@@ -911,34 +1074,20 @@ async function handleApi(request, response, context) {
|
|
|
911
1074
|
});
|
|
912
1075
|
return;
|
|
913
1076
|
}
|
|
914
|
-
if (method === 'GET' && pathname === '/api/
|
|
915
|
-
await withMetric('
|
|
916
|
-
const limitRaw = Number(url.searchParams.get('limit') ?? '120');
|
|
917
|
-
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', () => {
|
|
918
1079
|
sendJson(response, 200, {
|
|
919
|
-
|
|
920
|
-
|
|
1080
|
+
lanes: lanesSummary(),
|
|
1081
|
+
count: Object.keys(lanes).length,
|
|
921
1082
|
});
|
|
922
1083
|
});
|
|
923
1084
|
return;
|
|
924
1085
|
}
|
|
925
|
-
if (method === 'GET' && pathname === '/api/events') {
|
|
926
|
-
await withMetric('events', () => {
|
|
927
|
-
setupSse(request, response);
|
|
928
|
-
});
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
if (method === 'GET' && pathname === '/api/metrics') {
|
|
932
|
-
await withMetric('metrics', () => {
|
|
933
|
-
sendJson(response, 200, buildMetricsPayload());
|
|
934
|
-
});
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
937
1086
|
if (method === 'GET' && pathname === '/api/jobs') {
|
|
938
1087
|
await withMetric('jobs-list', () => {
|
|
939
1088
|
sendJson(response, 200, {
|
|
940
1089
|
jobs: listJobSummaries(),
|
|
941
|
-
queueDepth:
|
|
1090
|
+
queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
|
|
942
1091
|
});
|
|
943
1092
|
});
|
|
944
1093
|
return;
|
|
@@ -948,7 +1097,7 @@ async function handleApi(request, response, context) {
|
|
|
948
1097
|
const body = await readJsonBody(request);
|
|
949
1098
|
const typeValue = typeof body.type === 'string' ? body.type : '';
|
|
950
1099
|
if (!isJobType(typeValue)) {
|
|
951
|
-
sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run' });
|
|
1100
|
+
sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run, device_profile' });
|
|
952
1101
|
return;
|
|
953
1102
|
}
|
|
954
1103
|
const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
|
|
@@ -958,7 +1107,37 @@ async function handleApi(request, response, context) {
|
|
|
958
1107
|
sendJson(response, 200, {
|
|
959
1108
|
ok: true,
|
|
960
1109
|
job,
|
|
961
|
-
|
|
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,
|
|
962
1141
|
});
|
|
963
1142
|
});
|
|
964
1143
|
return;
|
|
@@ -987,6 +1166,22 @@ async function handleApi(request, response, context) {
|
|
|
987
1166
|
});
|
|
988
1167
|
return;
|
|
989
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
|
+
}
|
|
990
1185
|
if (method === 'GET' && pathname === '/api/workflows') {
|
|
991
1186
|
await withMetric('workflows-list', () => {
|
|
992
1187
|
sendJson(response, 200, {
|
|
@@ -1011,7 +1206,7 @@ async function handleApi(request, response, context) {
|
|
|
1011
1206
|
const replace = body.replace === true;
|
|
1012
1207
|
const payload = body.workflows;
|
|
1013
1208
|
const imported = replace ? {} : { ...workflows };
|
|
1014
|
-
const
|
|
1209
|
+
const consumeWorkflow = (value) => {
|
|
1015
1210
|
if (!value || typeof value !== 'object') {
|
|
1016
1211
|
return;
|
|
1017
1212
|
}
|
|
@@ -1022,8 +1217,8 @@ async function handleApi(request, response, context) {
|
|
|
1022
1217
|
}
|
|
1023
1218
|
const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
|
|
1024
1219
|
const steps = [];
|
|
1025
|
-
for (const
|
|
1026
|
-
steps.push(normalizeWorkflowStep(
|
|
1220
|
+
for (const stepRaw of stepsRaw) {
|
|
1221
|
+
steps.push(normalizeWorkflowStep(stepRaw));
|
|
1027
1222
|
}
|
|
1028
1223
|
if (steps.length === 0) {
|
|
1029
1224
|
return;
|
|
@@ -1037,12 +1232,12 @@ async function handleApi(request, response, context) {
|
|
|
1037
1232
|
};
|
|
1038
1233
|
if (Array.isArray(payload)) {
|
|
1039
1234
|
for (const value of payload) {
|
|
1040
|
-
|
|
1235
|
+
consumeWorkflow(value);
|
|
1041
1236
|
}
|
|
1042
1237
|
}
|
|
1043
1238
|
else if (payload && typeof payload === 'object') {
|
|
1044
1239
|
for (const value of Object.values(payload)) {
|
|
1045
|
-
|
|
1240
|
+
consumeWorkflow(value);
|
|
1046
1241
|
}
|
|
1047
1242
|
}
|
|
1048
1243
|
else {
|
|
@@ -1170,6 +1365,7 @@ async function handleApi(request, response, context) {
|
|
|
1170
1365
|
pushEvent('device-profile', 'Device profile captured', {
|
|
1171
1366
|
deviceId: profile.deviceId,
|
|
1172
1367
|
durationMs: profile.durationMs,
|
|
1368
|
+
healthScore: profile.healthScore,
|
|
1173
1369
|
});
|
|
1174
1370
|
sendJson(response, 200, {
|
|
1175
1371
|
ok: true,
|
|
@@ -1179,6 +1375,27 @@ async function handleApi(request, response, context) {
|
|
|
1179
1375
|
});
|
|
1180
1376
|
return;
|
|
1181
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
|
+
}
|
|
1182
1399
|
if (method === 'POST' && pathname === '/api/snapshot-suite') {
|
|
1183
1400
|
await withMetric('snapshot-suite', async () => {
|
|
1184
1401
|
const body = await readJsonBody(request);
|
|
@@ -1202,15 +1419,15 @@ async function handleApi(request, response, context) {
|
|
|
1202
1419
|
sendJson(response, 400, { error: 'kind must be one of snapshot kinds' });
|
|
1203
1420
|
return;
|
|
1204
1421
|
}
|
|
1205
|
-
const
|
|
1422
|
+
const previous = snapshotCache[kindValue];
|
|
1206
1423
|
const fresh = captureSnapshot(kindValue, {
|
|
1207
1424
|
deviceId: extractDeviceId(body),
|
|
1208
1425
|
packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
|
|
1209
1426
|
includeRaw: true,
|
|
1210
1427
|
});
|
|
1211
|
-
const
|
|
1212
|
-
snapshotCache[kindValue] =
|
|
1213
|
-
const diff = diffSnapshot(
|
|
1428
|
+
const currentRaw = fresh.raw;
|
|
1429
|
+
snapshotCache[kindValue] = currentRaw;
|
|
1430
|
+
const diff = diffSnapshot(previous, currentRaw);
|
|
1214
1431
|
pushEvent('snapshot-diff', 'Snapshot diff captured', {
|
|
1215
1432
|
kind: kindValue,
|
|
1216
1433
|
changedCount: diff.changedCount,
|
|
@@ -1218,7 +1435,7 @@ async function handleApi(request, response, context) {
|
|
|
1218
1435
|
sendJson(response, 200, {
|
|
1219
1436
|
ok: true,
|
|
1220
1437
|
kind: kindValue,
|
|
1221
|
-
hadPrevious: Boolean(
|
|
1438
|
+
hadPrevious: Boolean(previous),
|
|
1222
1439
|
diff,
|
|
1223
1440
|
currentSummary: fresh.summary,
|
|
1224
1441
|
updateHint: UPDATE_HINT,
|
|
@@ -1263,6 +1480,47 @@ async function handleApi(request, response, context) {
|
|
|
1263
1480
|
});
|
|
1264
1481
|
return;
|
|
1265
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
|
+
}
|
|
1266
1524
|
sendJson(response, 404, { error: 'Not found' });
|
|
1267
1525
|
}
|
|
1268
1526
|
const INDEX_HTML = String.raw `<!doctype html>
|
|
@@ -1270,7 +1528,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1270
1528
|
<head>
|
|
1271
1529
|
<meta charset="utf-8" />
|
|
1272
1530
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1273
|
-
<title>the-android-mcp web ui v3.
|
|
1531
|
+
<title>the-android-mcp web ui v3.4</title>
|
|
1274
1532
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
1275
1533
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
1276
1534
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
@@ -1301,7 +1559,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1301
1559
|
linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
|
|
1302
1560
|
padding: 16px;
|
|
1303
1561
|
}
|
|
1304
|
-
.app { max-width:
|
|
1562
|
+
.app { max-width: 1560px; margin: 0 auto; display: grid; gap: 12px; }
|
|
1305
1563
|
.hero {
|
|
1306
1564
|
border: 1px solid var(--line);
|
|
1307
1565
|
border-radius: 18px;
|
|
@@ -1353,13 +1611,13 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1353
1611
|
padding: 9px;
|
|
1354
1612
|
font: inherit;
|
|
1355
1613
|
}
|
|
1356
|
-
textarea { min-height:
|
|
1614
|
+
textarea { min-height: 86px; resize: vertical; }
|
|
1357
1615
|
button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
|
|
1358
1616
|
button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
|
1359
1617
|
.p { background: linear-gradient(130deg, #0d7666, #155f75); }
|
|
1360
1618
|
.s { background: linear-gradient(130deg, #1d3348, #192b3c); }
|
|
1361
1619
|
.w { background: linear-gradient(130deg, #7a5917, #61401f); }
|
|
1362
|
-
.events, .metrics, .jobs {
|
|
1620
|
+
.events, .metrics, .jobs, .lanes {
|
|
1363
1621
|
max-height: 300px;
|
|
1364
1622
|
overflow: auto;
|
|
1365
1623
|
border: 1px solid #2f4a61;
|
|
@@ -1391,7 +1649,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1391
1649
|
pre {
|
|
1392
1650
|
margin: 0;
|
|
1393
1651
|
padding: 10px;
|
|
1394
|
-
max-height:
|
|
1652
|
+
max-height: 460px;
|
|
1395
1653
|
overflow: auto;
|
|
1396
1654
|
color: #c6e4ff;
|
|
1397
1655
|
font: 12px/1.45 'JetBrains Mono', monospace;
|
|
@@ -1411,11 +1669,12 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1411
1669
|
<span class="pill" id="version-pill">v3</span>
|
|
1412
1670
|
<span class="pill" id="device-pill">device: n/a</span>
|
|
1413
1671
|
<span class="pill" id="workflow-pill">workflows: 0</span>
|
|
1672
|
+
<span class="pill" id="lane-pill">lanes: 0</span>
|
|
1414
1673
|
<span class="pill" id="queue-pill">queue: 0</span>
|
|
1415
1674
|
<span class="pill">port: 50000</span>
|
|
1416
1675
|
</div>
|
|
1417
|
-
<h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.
|
|
1418
|
-
<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>
|
|
1419
1678
|
</section>
|
|
1420
1679
|
|
|
1421
1680
|
<section class="grid">
|
|
@@ -1435,20 +1694,13 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1435
1694
|
</div>
|
|
1436
1695
|
<div class="split">
|
|
1437
1696
|
<button class="s" id="profile-btn">Device profile</button>
|
|
1438
|
-
<button class="
|
|
1439
|
-
</div>
|
|
1440
|
-
<textarea id="stress-urls">https://www.wikipedia.org
|
|
1441
|
-
https://news.ycombinator.com
|
|
1442
|
-
https://developer.android.com</textarea>
|
|
1443
|
-
<div class="split">
|
|
1444
|
-
<input id="stress-loops" type="number" min="1" max="5" value="1" />
|
|
1445
|
-
<input id="stress-wait" type="number" min="200" max="6000" value="1000" />
|
|
1697
|
+
<button class="s" id="profiles-btn">Profiles all</button>
|
|
1446
1698
|
</div>
|
|
1447
1699
|
<p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
|
|
1448
1700
|
</article>
|
|
1449
1701
|
|
|
1450
1702
|
<article class="card stack">
|
|
1451
|
-
<h2>Snapshot +
|
|
1703
|
+
<h2>Snapshot + stress</h2>
|
|
1452
1704
|
<select id="snapshot-kind">
|
|
1453
1705
|
<option value="radio">radio</option>
|
|
1454
1706
|
<option value="display">display</option>
|
|
@@ -1460,12 +1712,22 @@ https://developer.android.com</textarea>
|
|
|
1460
1712
|
<button class="s" id="snapshot-btn">Capture</button>
|
|
1461
1713
|
<button class="w" id="snapshot-diff-btn">Capture diff</button>
|
|
1462
1714
|
</div>
|
|
1715
|
+
<textarea id="stress-urls">https://www.wikipedia.org
|
|
1716
|
+
https://news.ycombinator.com
|
|
1717
|
+
https://developer.android.com</textarea>
|
|
1718
|
+
<div class="split">
|
|
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" />
|
|
1721
|
+
</div>
|
|
1722
|
+
<button class="w" id="stress-btn">Run stress</button>
|
|
1723
|
+
</article>
|
|
1463
1724
|
|
|
1464
|
-
|
|
1725
|
+
<article class="card stack">
|
|
1726
|
+
<h2>Workflow engine</h2>
|
|
1465
1727
|
<select id="workflow-select"></select>
|
|
1466
|
-
<input id="workflow-name" placeholder="workflow name"
|
|
1728
|
+
<input id="workflow-name" placeholder="workflow name" />
|
|
1467
1729
|
<textarea id="workflow-steps">[
|
|
1468
|
-
{"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":
|
|
1730
|
+
{"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":900},
|
|
1469
1731
|
{"type":"snapshot","snapshot":"radio"},
|
|
1470
1732
|
{"type":"snapshot_suite","packageName":"com.android.chrome"}
|
|
1471
1733
|
]</textarea>
|
|
@@ -1487,6 +1749,7 @@ https://developer.android.com</textarea>
|
|
|
1487
1749
|
<option value="snapshot_suite">snapshot_suite</option>
|
|
1488
1750
|
<option value="stress_run">stress_run</option>
|
|
1489
1751
|
<option value="workflow_run">workflow_run</option>
|
|
1752
|
+
<option value="device_profile">device_profile</option>
|
|
1490
1753
|
</select>
|
|
1491
1754
|
<input id="job-url" type="url" value="https://developer.android.com" />
|
|
1492
1755
|
<select id="job-workflow"></select>
|
|
@@ -1494,18 +1757,29 @@ https://developer.android.com</textarea>
|
|
|
1494
1757
|
<button class="p" id="job-enqueue-btn">Enqueue job</button>
|
|
1495
1758
|
<button class="s" id="job-refresh-btn">Refresh jobs</button>
|
|
1496
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>
|
|
1497
1766
|
<div id="jobs" class="jobs"></div>
|
|
1498
1767
|
</article>
|
|
1499
1768
|
|
|
1500
1769
|
<article class="card stack">
|
|
1501
|
-
<h2>
|
|
1770
|
+
<h2>Lanes + events</h2>
|
|
1771
|
+
<div id="lanes" class="lanes"></div>
|
|
1502
1772
|
<div id="events" class="events"></div>
|
|
1503
1773
|
</article>
|
|
1504
1774
|
|
|
1505
1775
|
<article class="card stack">
|
|
1506
|
-
<h2>
|
|
1776
|
+
<h2>Metrics + session</h2>
|
|
1507
1777
|
<div id="metrics" class="metrics"></div>
|
|
1508
|
-
<
|
|
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>
|
|
1509
1783
|
</article>
|
|
1510
1784
|
|
|
1511
1785
|
<article class="card" style="grid-column: 1 / -1;">
|
|
@@ -1524,13 +1798,13 @@ https://developer.android.com</textarea>
|
|
|
1524
1798
|
const state = {
|
|
1525
1799
|
deviceId: undefined,
|
|
1526
1800
|
workflows: [],
|
|
1527
|
-
jobs: [],
|
|
1528
1801
|
};
|
|
1529
1802
|
|
|
1530
1803
|
const $status = document.getElementById('status');
|
|
1531
1804
|
const $versionPill = document.getElementById('version-pill');
|
|
1532
1805
|
const $devicePill = document.getElementById('device-pill');
|
|
1533
1806
|
const $workflowPill = document.getElementById('workflow-pill');
|
|
1807
|
+
const $lanePill = document.getElementById('lane-pill');
|
|
1534
1808
|
const $queuePill = document.getElementById('queue-pill');
|
|
1535
1809
|
const $deviceSelect = document.getElementById('device-select');
|
|
1536
1810
|
const $urlInput = document.getElementById('url-input');
|
|
@@ -1539,6 +1813,7 @@ https://developer.android.com</textarea>
|
|
|
1539
1813
|
const $events = document.getElementById('events');
|
|
1540
1814
|
const $metrics = document.getElementById('metrics');
|
|
1541
1815
|
const $jobs = document.getElementById('jobs');
|
|
1816
|
+
const $lanes = document.getElementById('lanes');
|
|
1542
1817
|
const $snapshotKind = document.getElementById('snapshot-kind');
|
|
1543
1818
|
const $stressUrls = document.getElementById('stress-urls');
|
|
1544
1819
|
const $stressLoops = document.getElementById('stress-loops');
|
|
@@ -1549,6 +1824,7 @@ https://developer.android.com</textarea>
|
|
|
1549
1824
|
const $jobType = document.getElementById('job-type');
|
|
1550
1825
|
const $jobUrl = document.getElementById('job-url');
|
|
1551
1826
|
const $jobWorkflow = document.getElementById('job-workflow');
|
|
1827
|
+
const $bulkJobs = document.getElementById('bulk-jobs');
|
|
1552
1828
|
|
|
1553
1829
|
function setMessage(text, isError) {
|
|
1554
1830
|
$message.textContent = text;
|
|
@@ -1566,20 +1842,10 @@ https://developer.android.com</textarea>
|
|
|
1566
1842
|
}
|
|
1567
1843
|
const item = document.createElement('div');
|
|
1568
1844
|
item.className = 'item';
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
text.textContent = event.message || '';
|
|
1574
|
-
item.appendChild(meta);
|
|
1575
|
-
item.appendChild(text);
|
|
1576
|
-
if (event.data) {
|
|
1577
|
-
const data = document.createElement('div');
|
|
1578
|
-
data.style.color = '#a5c6db';
|
|
1579
|
-
data.style.marginTop = '4px';
|
|
1580
|
-
data.textContent = JSON.stringify(event.data);
|
|
1581
|
-
item.appendChild(data);
|
|
1582
|
-
}
|
|
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>' : '');
|
|
1583
1849
|
$events.prepend(item);
|
|
1584
1850
|
while ($events.children.length > 120) {
|
|
1585
1851
|
$events.removeChild($events.lastChild);
|
|
@@ -1589,7 +1855,7 @@ https://developer.android.com</textarea>
|
|
|
1589
1855
|
function renderMetrics(payload) {
|
|
1590
1856
|
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
1591
1857
|
$metrics.innerHTML = '';
|
|
1592
|
-
for (const entry of entries.slice(0,
|
|
1858
|
+
for (const entry of entries.slice(0, 25)) {
|
|
1593
1859
|
const item = document.createElement('div');
|
|
1594
1860
|
item.className = 'item';
|
|
1595
1861
|
item.innerHTML =
|
|
@@ -1603,35 +1869,70 @@ https://developer.android.com</textarea>
|
|
|
1603
1869
|
}
|
|
1604
1870
|
}
|
|
1605
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
|
+
|
|
1606
1889
|
function renderJobs(payload) {
|
|
1607
1890
|
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
|
1608
|
-
state.jobs = jobs;
|
|
1609
1891
|
$jobs.innerHTML = '';
|
|
1610
1892
|
for (const job of jobs.slice(0, 40)) {
|
|
1611
1893
|
const item = document.createElement('div');
|
|
1612
1894
|
item.className = 'item';
|
|
1613
1895
|
item.innerHTML =
|
|
1614
1896
|
'<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
|
|
1615
|
-
'<div>
|
|
1616
|
-
'<div>duration=' + (job.durationMs || 0) + 'ms</div>' +
|
|
1897
|
+
'<div>lane=' + (job.laneId || '-') + ' duration=' + (job.durationMs || 0) + 'ms</div>' +
|
|
1617
1898
|
(job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
|
|
1899
|
+
|
|
1618
1900
|
if (job.status === 'queued') {
|
|
1619
|
-
const
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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 () {
|
|
1624
1906
|
try {
|
|
1625
1907
|
const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
|
|
1626
1908
|
renderOutput(result);
|
|
1627
|
-
await
|
|
1909
|
+
await refreshJobsAndLanes();
|
|
1628
1910
|
setMessage('Job cancelled', false);
|
|
1629
1911
|
} catch (error) {
|
|
1630
1912
|
setMessage(String(error), true);
|
|
1631
1913
|
}
|
|
1632
1914
|
});
|
|
1633
|
-
item.appendChild(
|
|
1915
|
+
item.appendChild(cancelBtn);
|
|
1634
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
|
+
|
|
1635
1936
|
$jobs.appendChild(item);
|
|
1636
1937
|
}
|
|
1637
1938
|
if (!jobs.length) {
|
|
@@ -1661,7 +1962,7 @@ https://developer.android.com</textarea>
|
|
|
1661
1962
|
return value || undefined;
|
|
1662
1963
|
}
|
|
1663
1964
|
|
|
1664
|
-
function
|
|
1965
|
+
function stressConfig() {
|
|
1665
1966
|
return {
|
|
1666
1967
|
urls: $stressUrls.value
|
|
1667
1968
|
.split('\n')
|
|
@@ -1673,9 +1974,12 @@ https://developer.android.com</textarea>
|
|
|
1673
1974
|
};
|
|
1674
1975
|
}
|
|
1675
1976
|
|
|
1676
|
-
async function
|
|
1677
|
-
const
|
|
1678
|
-
renderJobs(
|
|
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);
|
|
1679
1983
|
}
|
|
1680
1984
|
|
|
1681
1985
|
async function refreshCore() {
|
|
@@ -1683,6 +1987,7 @@ https://developer.android.com</textarea>
|
|
|
1683
1987
|
$status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
|
|
1684
1988
|
$versionPill.textContent = 'v' + statePayload.version;
|
|
1685
1989
|
$workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
|
|
1990
|
+
$lanePill.textContent = 'lanes: ' + statePayload.laneCount;
|
|
1686
1991
|
$queuePill.textContent = 'queue: ' + statePayload.queueDepth;
|
|
1687
1992
|
|
|
1688
1993
|
const devicesPayload = await api('/api/devices');
|
|
@@ -1697,11 +2002,10 @@ https://developer.android.com</textarea>
|
|
|
1697
2002
|
$deviceSelect.appendChild(option);
|
|
1698
2003
|
}
|
|
1699
2004
|
|
|
1700
|
-
state.deviceId = undefined;
|
|
1701
2005
|
if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
|
|
1702
2006
|
state.deviceId = previousDevice;
|
|
1703
|
-
} else
|
|
1704
|
-
state.deviceId = devices[0].id;
|
|
2007
|
+
} else {
|
|
2008
|
+
state.deviceId = devices[0] ? devices[0].id : undefined;
|
|
1705
2009
|
}
|
|
1706
2010
|
|
|
1707
2011
|
if (state.deviceId) {
|
|
@@ -1715,24 +2019,24 @@ https://developer.android.com</textarea>
|
|
|
1715
2019
|
const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
|
|
1716
2020
|
state.workflows = workflows;
|
|
1717
2021
|
|
|
1718
|
-
const
|
|
1719
|
-
const prev = (
|
|
1720
|
-
|
|
1721
|
-
for (const
|
|
2022
|
+
const fillWorkflowSelect = function (selectElement) {
|
|
2023
|
+
const prev = (selectElement.value || '').trim();
|
|
2024
|
+
selectElement.innerHTML = '';
|
|
2025
|
+
for (const wf of workflows) {
|
|
1722
2026
|
const option = document.createElement('option');
|
|
1723
|
-
option.value =
|
|
1724
|
-
option.textContent =
|
|
1725
|
-
|
|
2027
|
+
option.value = wf.name;
|
|
2028
|
+
option.textContent = wf.name;
|
|
2029
|
+
selectElement.appendChild(option);
|
|
1726
2030
|
}
|
|
1727
2031
|
if (prev && workflows.some(function (w) { return w.name === prev; })) {
|
|
1728
|
-
|
|
2032
|
+
selectElement.value = prev;
|
|
1729
2033
|
} else if (workflows[0]) {
|
|
1730
|
-
|
|
2034
|
+
selectElement.value = workflows[0].name;
|
|
1731
2035
|
}
|
|
1732
2036
|
};
|
|
1733
2037
|
|
|
1734
|
-
|
|
1735
|
-
|
|
2038
|
+
fillWorkflowSelect($workflowSelect);
|
|
2039
|
+
fillWorkflowSelect($jobWorkflow);
|
|
1736
2040
|
|
|
1737
2041
|
if ($workflowSelect.value) {
|
|
1738
2042
|
const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
@@ -1745,7 +2049,7 @@ https://developer.android.com</textarea>
|
|
|
1745
2049
|
const metricsPayload = await api('/api/metrics');
|
|
1746
2050
|
renderMetrics(metricsPayload);
|
|
1747
2051
|
|
|
1748
|
-
await
|
|
2052
|
+
await refreshJobsAndLanes();
|
|
1749
2053
|
}
|
|
1750
2054
|
|
|
1751
2055
|
function connectEvents() {
|
|
@@ -1753,7 +2057,7 @@ https://developer.android.com</textarea>
|
|
|
1753
2057
|
es.onmessage = function (event) {
|
|
1754
2058
|
try {
|
|
1755
2059
|
addEvent(JSON.parse(event.data));
|
|
1756
|
-
} catch (
|
|
2060
|
+
} catch (error) {
|
|
1757
2061
|
// ignore
|
|
1758
2062
|
}
|
|
1759
2063
|
};
|
|
@@ -1763,8 +2067,8 @@ https://developer.android.com</textarea>
|
|
|
1763
2067
|
setMessage('Opening URL: ' + url, false);
|
|
1764
2068
|
const result = await api('/api/open-url', 'POST', {
|
|
1765
2069
|
deviceId: selectedDeviceId(),
|
|
1766
|
-
url
|
|
1767
|
-
waitForReadyMs:
|
|
2070
|
+
url,
|
|
2071
|
+
waitForReadyMs: 900,
|
|
1768
2072
|
});
|
|
1769
2073
|
renderOutput(result);
|
|
1770
2074
|
setMessage('URL opened', false);
|
|
@@ -1794,11 +2098,11 @@ https://developer.android.com</textarea>
|
|
|
1794
2098
|
|
|
1795
2099
|
async function captureSnapshotDiff() {
|
|
1796
2100
|
const kind = $snapshotKind.value;
|
|
1797
|
-
setMessage('Capturing diff: ' + kind, false);
|
|
2101
|
+
setMessage('Capturing snapshot diff: ' + kind, false);
|
|
1798
2102
|
const result = await api('/api/snapshot/diff', 'POST', {
|
|
1799
2103
|
deviceId: selectedDeviceId(),
|
|
1800
2104
|
packageName: 'com.android.chrome',
|
|
1801
|
-
kind
|
|
2105
|
+
kind,
|
|
1802
2106
|
});
|
|
1803
2107
|
renderOutput(result);
|
|
1804
2108
|
setMessage('Snapshot diff completed', false);
|
|
@@ -1811,12 +2115,21 @@ https://developer.android.com</textarea>
|
|
|
1811
2115
|
packageName: 'com.android.chrome',
|
|
1812
2116
|
});
|
|
1813
2117
|
renderOutput(result);
|
|
1814
|
-
setMessage('Device profile
|
|
2118
|
+
setMessage('Device profile completed', false);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
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);
|
|
1815
2128
|
}
|
|
1816
2129
|
|
|
1817
2130
|
async function runStress() {
|
|
1818
|
-
setMessage('Running stress', false);
|
|
1819
|
-
const cfg =
|
|
2131
|
+
setMessage('Running stress run', false);
|
|
2132
|
+
const cfg = stressConfig();
|
|
1820
2133
|
const result = await api('/api/stress-run', 'POST', {
|
|
1821
2134
|
deviceId: selectedDeviceId(),
|
|
1822
2135
|
urls: cfg.urls,
|
|
@@ -1825,7 +2138,7 @@ https://developer.android.com</textarea>
|
|
|
1825
2138
|
includeSnapshotAfterEach: true,
|
|
1826
2139
|
});
|
|
1827
2140
|
renderOutput(result);
|
|
1828
|
-
setMessage('Stress run
|
|
2141
|
+
setMessage('Stress run completed', false);
|
|
1829
2142
|
}
|
|
1830
2143
|
|
|
1831
2144
|
async function saveWorkflow() {
|
|
@@ -1839,10 +2152,7 @@ https://developer.android.com</textarea>
|
|
|
1839
2152
|
} catch (error) {
|
|
1840
2153
|
throw new Error('workflow steps must be valid JSON');
|
|
1841
2154
|
}
|
|
1842
|
-
const result = await api('/api/workflows', 'POST', {
|
|
1843
|
-
name,
|
|
1844
|
-
steps,
|
|
1845
|
-
});
|
|
2155
|
+
const result = await api('/api/workflows', 'POST', { name, steps });
|
|
1846
2156
|
renderOutput(result);
|
|
1847
2157
|
setMessage('Workflow saved', false);
|
|
1848
2158
|
await refreshCore();
|
|
@@ -1878,7 +2188,7 @@ https://developer.android.com</textarea>
|
|
|
1878
2188
|
const result = await api('/api/workflows/export');
|
|
1879
2189
|
$workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
|
|
1880
2190
|
renderOutput(result);
|
|
1881
|
-
setMessage('Workflows exported
|
|
2191
|
+
setMessage('Workflows exported to editor', false);
|
|
1882
2192
|
}
|
|
1883
2193
|
|
|
1884
2194
|
async function importWorkflows() {
|
|
@@ -1886,9 +2196,8 @@ https://developer.android.com</textarea>
|
|
|
1886
2196
|
try {
|
|
1887
2197
|
parsed = JSON.parse($workflowSteps.value || '{}');
|
|
1888
2198
|
} catch (error) {
|
|
1889
|
-
throw new Error('workflow editor
|
|
2199
|
+
throw new Error('workflow editor must contain valid JSON');
|
|
1890
2200
|
}
|
|
1891
|
-
|
|
1892
2201
|
const result = await api('/api/workflows/import', 'POST', {
|
|
1893
2202
|
workflows: parsed,
|
|
1894
2203
|
replace: false,
|
|
@@ -1898,17 +2207,17 @@ https://developer.android.com</textarea>
|
|
|
1898
2207
|
await refreshCore();
|
|
1899
2208
|
}
|
|
1900
2209
|
|
|
1901
|
-
async function
|
|
2210
|
+
async function enqueueSingleJob() {
|
|
1902
2211
|
const type = ($jobType.value || '').trim();
|
|
1903
2212
|
const input = { deviceId: selectedDeviceId() };
|
|
1904
2213
|
|
|
1905
2214
|
if (type === 'open_url') {
|
|
1906
2215
|
input.url = $jobUrl.value || 'https://developer.android.com';
|
|
1907
|
-
input.waitForReadyMs =
|
|
2216
|
+
input.waitForReadyMs = 900;
|
|
1908
2217
|
} else if (type === 'snapshot_suite') {
|
|
1909
2218
|
input.packageName = 'com.android.chrome';
|
|
1910
2219
|
} else if (type === 'stress_run') {
|
|
1911
|
-
const cfg =
|
|
2220
|
+
const cfg = stressConfig();
|
|
1912
2221
|
input.urls = cfg.urls;
|
|
1913
2222
|
input.loops = cfg.loops;
|
|
1914
2223
|
input.waitForReadyMs = cfg.waitForReadyMs;
|
|
@@ -1920,16 +2229,54 @@ https://developer.android.com</textarea>
|
|
|
1920
2229
|
}
|
|
1921
2230
|
input.name = workflowName;
|
|
1922
2231
|
input.packageName = 'com.android.chrome';
|
|
1923
|
-
|
|
2232
|
+
} else if (type === 'device_profile') {
|
|
2233
|
+
input.packageName = 'com.android.chrome';
|
|
1924
2234
|
}
|
|
1925
2235
|
|
|
1926
|
-
const result = await api('/api/jobs', 'POST', {
|
|
1927
|
-
type,
|
|
1928
|
-
input,
|
|
1929
|
-
});
|
|
2236
|
+
const result = await api('/api/jobs', 'POST', { type, input });
|
|
1930
2237
|
renderOutput(result);
|
|
1931
2238
|
setMessage('Job enqueued', false);
|
|
1932
|
-
await
|
|
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();
|
|
1933
2280
|
}
|
|
1934
2281
|
|
|
1935
2282
|
document.getElementById('open-url-btn').addEventListener('click', async function () {
|
|
@@ -1947,6 +2294,9 @@ https://developer.android.com</textarea>
|
|
|
1947
2294
|
document.getElementById('profile-btn').addEventListener('click', async function () {
|
|
1948
2295
|
try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
|
|
1949
2296
|
});
|
|
2297
|
+
document.getElementById('profiles-btn').addEventListener('click', async function () {
|
|
2298
|
+
try { await captureAllProfiles(); } catch (error) { setMessage(String(error), true); }
|
|
2299
|
+
});
|
|
1950
2300
|
document.getElementById('stress-btn').addEventListener('click', async function () {
|
|
1951
2301
|
try { await runStress(); } catch (error) { setMessage(String(error), true); }
|
|
1952
2302
|
});
|
|
@@ -1966,14 +2316,23 @@ https://developer.android.com</textarea>
|
|
|
1966
2316
|
try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
|
|
1967
2317
|
});
|
|
1968
2318
|
document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
|
|
1969
|
-
try { await
|
|
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); }
|
|
1970
2323
|
});
|
|
1971
2324
|
document.getElementById('job-refresh-btn').addEventListener('click', async function () {
|
|
1972
|
-
try { await
|
|
2325
|
+
try { await refreshJobsAndLanes(); setMessage('Jobs and lanes refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
1973
2326
|
});
|
|
1974
2327
|
document.getElementById('refresh-state-btn').addEventListener('click', async function () {
|
|
1975
2328
|
try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
1976
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); }
|
|
2335
|
+
});
|
|
1977
2336
|
document.getElementById('clear-output-btn').addEventListener('click', function () {
|
|
1978
2337
|
renderOutput({});
|
|
1979
2338
|
});
|
|
@@ -2005,10 +2364,91 @@ https://developer.android.com</textarea>
|
|
|
2005
2364
|
}
|
|
2006
2365
|
});
|
|
2007
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
|
+
|
|
2008
2448
|
async function init() {
|
|
2009
2449
|
try {
|
|
2010
2450
|
await refreshCore();
|
|
2011
|
-
const history = await api('/api/history?limit=
|
|
2451
|
+
const history = await api('/api/history?limit=45');
|
|
2012
2452
|
const events = Array.isArray(history.events) ? history.events : [];
|
|
2013
2453
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2014
2454
|
addEvent(events[i]);
|
|
@@ -2021,7 +2461,7 @@ https://developer.android.com</textarea>
|
|
|
2021
2461
|
try {
|
|
2022
2462
|
const metricsPayload = await api('/api/metrics');
|
|
2023
2463
|
renderMetrics(metricsPayload);
|
|
2024
|
-
await
|
|
2464
|
+
await refreshJobsAndLanes();
|
|
2025
2465
|
} catch (error) {
|
|
2026
2466
|
setMessage('Background refresh failed', true);
|
|
2027
2467
|
}
|