the-android-mcp 3.2.0 → 3.3.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 +712 -178
- package/dist/web-ui.js.map +1 -1
- package/package.json +1 -1
package/dist/web-ui.js
CHANGED
|
@@ -20,6 +20,7 @@ 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
22
|
const MAX_EVENTS = 500;
|
|
23
|
+
const MAX_JOBS = 250;
|
|
23
24
|
const WORKFLOW_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
|
|
24
25
|
const WORKFLOW_FILE = path_1.default.join(WORKFLOW_DIR, 'web-ui-workflows.json');
|
|
25
26
|
const SNAPSHOT_KINDS = [
|
|
@@ -31,17 +32,24 @@ const SNAPSHOT_KINDS = [
|
|
|
31
32
|
];
|
|
32
33
|
const serverStartedAt = Date.now();
|
|
33
34
|
let eventSeq = 1;
|
|
35
|
+
let jobSeq = 1;
|
|
34
36
|
const eventHistory = [];
|
|
35
37
|
const sseClients = new Set();
|
|
36
38
|
const metrics = {};
|
|
37
39
|
const snapshotCache = {};
|
|
38
40
|
let workflows = loadWorkflows();
|
|
41
|
+
const jobs = [];
|
|
42
|
+
const jobQueue = [];
|
|
43
|
+
let jobRunnerActive = false;
|
|
39
44
|
function nowIso() {
|
|
40
45
|
return new Date().toISOString();
|
|
41
46
|
}
|
|
42
47
|
function isSnapshotKind(value) {
|
|
43
48
|
return SNAPSHOT_KINDS.includes(value);
|
|
44
49
|
}
|
|
50
|
+
function isJobType(value) {
|
|
51
|
+
return value === 'open_url' || value === 'snapshot_suite' || value === 'stress_run' || value === 'workflow_run';
|
|
52
|
+
}
|
|
45
53
|
function clampInt(value, fallback, min, max) {
|
|
46
54
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
47
55
|
return fallback;
|
|
@@ -49,6 +57,38 @@ function clampInt(value, fallback, min, max) {
|
|
|
49
57
|
const parsed = Math.trunc(value);
|
|
50
58
|
return Math.max(min, Math.min(max, parsed));
|
|
51
59
|
}
|
|
60
|
+
function pushEvent(type, message, data) {
|
|
61
|
+
const event = {
|
|
62
|
+
id: eventSeq++,
|
|
63
|
+
at: nowIso(),
|
|
64
|
+
type,
|
|
65
|
+
message,
|
|
66
|
+
data,
|
|
67
|
+
};
|
|
68
|
+
eventHistory.push(event);
|
|
69
|
+
if (eventHistory.length > MAX_EVENTS) {
|
|
70
|
+
eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
|
|
71
|
+
}
|
|
72
|
+
broadcastEvent(event);
|
|
73
|
+
return event;
|
|
74
|
+
}
|
|
75
|
+
function broadcastEvent(event) {
|
|
76
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
77
|
+
for (const client of sseClients) {
|
|
78
|
+
try {
|
|
79
|
+
client.write(payload);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
try {
|
|
83
|
+
client.end();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
sseClients.delete(client);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
52
92
|
function trackMetric(name, durationMs, ok, errorMessage) {
|
|
53
93
|
const entry = metrics[name] ?? {
|
|
54
94
|
name,
|
|
@@ -84,38 +124,6 @@ async function withMetric(name, fn) {
|
|
|
84
124
|
throw error;
|
|
85
125
|
}
|
|
86
126
|
}
|
|
87
|
-
function pushEvent(type, message, data) {
|
|
88
|
-
const event = {
|
|
89
|
-
id: eventSeq++,
|
|
90
|
-
at: nowIso(),
|
|
91
|
-
type,
|
|
92
|
-
message,
|
|
93
|
-
data,
|
|
94
|
-
};
|
|
95
|
-
eventHistory.push(event);
|
|
96
|
-
if (eventHistory.length > MAX_EVENTS) {
|
|
97
|
-
eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
|
|
98
|
-
}
|
|
99
|
-
broadcastEvent(event);
|
|
100
|
-
return event;
|
|
101
|
-
}
|
|
102
|
-
function broadcastEvent(event) {
|
|
103
|
-
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
104
|
-
for (const client of sseClients) {
|
|
105
|
-
try {
|
|
106
|
-
client.write(payload);
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
try {
|
|
110
|
-
client.end();
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// ignore
|
|
114
|
-
}
|
|
115
|
-
sseClients.delete(client);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
127
|
function sendJson(response, statusCode, body) {
|
|
120
128
|
response.statusCode = statusCode;
|
|
121
129
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -167,7 +175,7 @@ function summarizeObjectShape(input) {
|
|
|
167
175
|
}
|
|
168
176
|
function normalizeWorkflowStep(value) {
|
|
169
177
|
if (!value || typeof value !== 'object') {
|
|
170
|
-
throw new Error('Invalid workflow step
|
|
178
|
+
throw new Error('Invalid workflow step: object expected');
|
|
171
179
|
}
|
|
172
180
|
const step = value;
|
|
173
181
|
const type = step.type;
|
|
@@ -180,7 +188,7 @@ function normalizeWorkflowStep(value) {
|
|
|
180
188
|
throw new Error('Workflow open_url step requires valid url');
|
|
181
189
|
}
|
|
182
190
|
normalized.url = step.url;
|
|
183
|
-
normalized.waitForReadyMs = clampInt(step.waitForReadyMs,
|
|
191
|
+
normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
|
|
184
192
|
return normalized;
|
|
185
193
|
}
|
|
186
194
|
if (type === 'snapshot') {
|
|
@@ -210,27 +218,25 @@ function ensureWorkflowStore() {
|
|
|
210
218
|
}
|
|
211
219
|
function defaultWorkflows() {
|
|
212
220
|
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
221
|
return {
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
},
|
|
234
240
|
};
|
|
235
241
|
}
|
|
236
242
|
function loadWorkflows() {
|
|
@@ -243,7 +249,7 @@ function loadWorkflows() {
|
|
|
243
249
|
}
|
|
244
250
|
const raw = fs_1.default.readFileSync(WORKFLOW_FILE, 'utf8');
|
|
245
251
|
const parsed = JSON.parse(raw);
|
|
246
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
252
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
247
253
|
return defaultWorkflows();
|
|
248
254
|
}
|
|
249
255
|
const result = {};
|
|
@@ -256,7 +262,18 @@ function loadWorkflows() {
|
|
|
256
262
|
continue;
|
|
257
263
|
}
|
|
258
264
|
const stepsValue = Array.isArray(item.steps) ? item.steps : [];
|
|
259
|
-
const steps =
|
|
265
|
+
const steps = [];
|
|
266
|
+
for (const stepValue of stepsValue) {
|
|
267
|
+
try {
|
|
268
|
+
steps.push(normalizeWorkflowStep(stepValue));
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// skip invalid step
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (steps.length === 0) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
260
277
|
result[name] = {
|
|
261
278
|
name,
|
|
262
279
|
description: typeof item.description === 'string' ? item.description : undefined,
|
|
@@ -361,7 +378,7 @@ function captureSnapshot(kind, options) {
|
|
|
361
378
|
}
|
|
362
379
|
function diffSnapshot(prev, next) {
|
|
363
380
|
if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') {
|
|
364
|
-
return { changedCount: 0, changed: [], note: 'No comparable snapshots' };
|
|
381
|
+
return { changedCount: 0, changed: [], note: 'No comparable snapshots available' };
|
|
365
382
|
}
|
|
366
383
|
const a = prev;
|
|
367
384
|
const b = next;
|
|
@@ -386,7 +403,12 @@ function diffSnapshot(prev, next) {
|
|
|
386
403
|
const oldJson = JSON.stringify(oldValue);
|
|
387
404
|
const newJson = JSON.stringify(newValue);
|
|
388
405
|
if (oldJson !== newJson) {
|
|
389
|
-
changed.push({
|
|
406
|
+
changed.push({
|
|
407
|
+
key,
|
|
408
|
+
type: 'value',
|
|
409
|
+
before: oldValue,
|
|
410
|
+
after: newValue,
|
|
411
|
+
});
|
|
390
412
|
}
|
|
391
413
|
}
|
|
392
414
|
return {
|
|
@@ -413,6 +435,76 @@ function getSnapshotSuite(deviceId, packageName) {
|
|
|
413
435
|
},
|
|
414
436
|
};
|
|
415
437
|
}
|
|
438
|
+
function buildDeviceProfile(deviceId, packageName) {
|
|
439
|
+
const startedAt = Date.now();
|
|
440
|
+
const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
|
|
441
|
+
deviceId,
|
|
442
|
+
includeWifiDump: false,
|
|
443
|
+
includeTelephonyDump: false,
|
|
444
|
+
includeBluetoothDump: false,
|
|
445
|
+
includeIpState: true,
|
|
446
|
+
includeConnectivityDump: false,
|
|
447
|
+
});
|
|
448
|
+
const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
|
|
449
|
+
deviceId,
|
|
450
|
+
includeDisplayDump: false,
|
|
451
|
+
includeWindowDump: false,
|
|
452
|
+
includeSurfaceFlinger: false,
|
|
453
|
+
});
|
|
454
|
+
const location = (0, adb_js_1.captureAndroidLocationSnapshot)({
|
|
455
|
+
deviceId,
|
|
456
|
+
packageName,
|
|
457
|
+
includeLocationDump: false,
|
|
458
|
+
includeLocationAppOps: true,
|
|
459
|
+
});
|
|
460
|
+
const power = (0, adb_js_1.captureAndroidPowerIdleSnapshot)({
|
|
461
|
+
deviceId,
|
|
462
|
+
includePowerDump: false,
|
|
463
|
+
includeDeviceIdle: false,
|
|
464
|
+
includeBatteryStats: false,
|
|
465
|
+
includeThermal: false,
|
|
466
|
+
});
|
|
467
|
+
const packages = (0, adb_js_1.captureAndroidPackageInventorySnapshot)({
|
|
468
|
+
deviceId,
|
|
469
|
+
includeThirdParty: true,
|
|
470
|
+
includeSystem: true,
|
|
471
|
+
includeDisabled: true,
|
|
472
|
+
includePackagePaths: false,
|
|
473
|
+
includeFeatures: false,
|
|
474
|
+
packageListLines: 600,
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
capturedAt: nowIso(),
|
|
478
|
+
durationMs: Date.now() - startedAt,
|
|
479
|
+
deviceId: radio.deviceId,
|
|
480
|
+
radio: {
|
|
481
|
+
wifiEnabled: radio.wifiEnabled,
|
|
482
|
+
mobileDataEnabled: radio.mobileDataEnabled,
|
|
483
|
+
airplaneMode: radio.airplaneMode,
|
|
484
|
+
bluetoothEnabled: radio.bluetoothEnabled,
|
|
485
|
+
},
|
|
486
|
+
display: {
|
|
487
|
+
wmSize: display.wmSize,
|
|
488
|
+
wmDensity: display.wmDensity,
|
|
489
|
+
brightness: display.screenBrightness,
|
|
490
|
+
rotation: display.userRotation,
|
|
491
|
+
},
|
|
492
|
+
location: {
|
|
493
|
+
mode: location.locationMode,
|
|
494
|
+
mockLocation: location.mockLocation,
|
|
495
|
+
appOps: compactStringSummary(location.locationAppOps),
|
|
496
|
+
},
|
|
497
|
+
power: {
|
|
498
|
+
battery: compactStringSummary(power.battery),
|
|
499
|
+
},
|
|
500
|
+
packages: {
|
|
501
|
+
total: packages.packageCount,
|
|
502
|
+
thirdParty: packages.thirdPartyCount,
|
|
503
|
+
system: packages.systemCount,
|
|
504
|
+
disabled: packages.disabledCount,
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
416
508
|
function runStressScenario(body) {
|
|
417
509
|
const deviceId = extractDeviceId(body);
|
|
418
510
|
const urlsValue = body.urls;
|
|
@@ -428,22 +520,22 @@ function runStressScenario(body) {
|
|
|
428
520
|
const startedAt = Date.now();
|
|
429
521
|
const steps = [];
|
|
430
522
|
for (let loop = 1; loop <= loops; loop += 1) {
|
|
431
|
-
for (const
|
|
432
|
-
const
|
|
433
|
-
const open = (0, adb_js_1.openUrlInChrome)(
|
|
523
|
+
for (const targetUrl of normalizedUrls) {
|
|
524
|
+
const openStarted = Date.now();
|
|
525
|
+
const open = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
|
|
434
526
|
waitForReadyMs,
|
|
435
527
|
fallbackToDefault: true,
|
|
436
528
|
});
|
|
437
|
-
const
|
|
529
|
+
const step = {
|
|
438
530
|
kind: 'open_url',
|
|
439
531
|
loop,
|
|
440
|
-
url,
|
|
532
|
+
url: targetUrl,
|
|
441
533
|
strategy: open.strategy,
|
|
442
534
|
deviceId: open.deviceId,
|
|
443
|
-
durationMs: Date.now() -
|
|
535
|
+
durationMs: Date.now() - openStarted,
|
|
444
536
|
};
|
|
445
537
|
if (includeSnapshotAfterEach) {
|
|
446
|
-
const
|
|
538
|
+
const snapStarted = Date.now();
|
|
447
539
|
const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({ deviceId: open.deviceId, includeWifiDump: false });
|
|
448
540
|
const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
|
|
449
541
|
deviceId: open.deviceId,
|
|
@@ -451,8 +543,8 @@ function runStressScenario(body) {
|
|
|
451
543
|
includeWindowDump: false,
|
|
452
544
|
includeSurfaceFlinger: false,
|
|
453
545
|
});
|
|
454
|
-
|
|
455
|
-
durationMs: Date.now() -
|
|
546
|
+
step.snapshot = {
|
|
547
|
+
durationMs: Date.now() - snapStarted,
|
|
456
548
|
radio: {
|
|
457
549
|
wifiEnabled: radio.wifiEnabled,
|
|
458
550
|
mobileDataEnabled: radio.mobileDataEnabled,
|
|
@@ -461,11 +553,11 @@ function runStressScenario(body) {
|
|
|
461
553
|
display: {
|
|
462
554
|
wmSize: display.wmSize,
|
|
463
555
|
wmDensity: display.wmDensity,
|
|
464
|
-
|
|
556
|
+
brightness: display.screenBrightness,
|
|
465
557
|
},
|
|
466
558
|
};
|
|
467
559
|
}
|
|
468
|
-
steps.push(
|
|
560
|
+
steps.push(step);
|
|
469
561
|
}
|
|
470
562
|
}
|
|
471
563
|
return {
|
|
@@ -492,7 +584,7 @@ async function runWorkflow(name, options) {
|
|
|
492
584
|
const stepStarted = Date.now();
|
|
493
585
|
if (step.type === 'open_url') {
|
|
494
586
|
const result = (0, adb_js_1.openUrlInChrome)(step.url || 'https://www.wikipedia.org', options.deviceId, {
|
|
495
|
-
waitForReadyMs: clampInt(step.waitForReadyMs,
|
|
587
|
+
waitForReadyMs: clampInt(step.waitForReadyMs, 1000, 200, 10000),
|
|
496
588
|
fallbackToDefault: true,
|
|
497
589
|
});
|
|
498
590
|
outputs.push({
|
|
@@ -505,15 +597,15 @@ async function runWorkflow(name, options) {
|
|
|
505
597
|
continue;
|
|
506
598
|
}
|
|
507
599
|
if (step.type === 'snapshot') {
|
|
508
|
-
const
|
|
509
|
-
const snapshot = captureSnapshot(
|
|
600
|
+
const kind = step.snapshot || 'radio';
|
|
601
|
+
const snapshot = captureSnapshot(kind, {
|
|
510
602
|
deviceId: options.deviceId,
|
|
511
603
|
packageName: step.packageName || options.packageName,
|
|
512
604
|
includeRaw: step.includeRaw === true || options.includeRaw === true,
|
|
513
605
|
});
|
|
514
606
|
outputs.push({
|
|
515
607
|
step: 'snapshot',
|
|
516
|
-
snapshotKind,
|
|
608
|
+
snapshotKind: kind,
|
|
517
609
|
durationMs: Date.now() - stepStarted,
|
|
518
610
|
snapshot,
|
|
519
611
|
});
|
|
@@ -548,6 +640,132 @@ async function runWorkflow(name, options) {
|
|
|
548
640
|
updateHint: UPDATE_HINT,
|
|
549
641
|
};
|
|
550
642
|
}
|
|
643
|
+
function createJob(type, input) {
|
|
644
|
+
const job = {
|
|
645
|
+
id: jobSeq++,
|
|
646
|
+
type,
|
|
647
|
+
status: 'queued',
|
|
648
|
+
createdAt: nowIso(),
|
|
649
|
+
input,
|
|
650
|
+
};
|
|
651
|
+
jobs.unshift(job);
|
|
652
|
+
if (jobs.length > MAX_JOBS) {
|
|
653
|
+
jobs.splice(MAX_JOBS);
|
|
654
|
+
}
|
|
655
|
+
jobQueue.push(job.id);
|
|
656
|
+
pushEvent('job-queued', 'Job queued', { id: job.id, type: job.type });
|
|
657
|
+
void runJobQueue();
|
|
658
|
+
return job;
|
|
659
|
+
}
|
|
660
|
+
function getJobById(id) {
|
|
661
|
+
return jobs.find(job => job.id === id);
|
|
662
|
+
}
|
|
663
|
+
function cancelQueuedJob(id) {
|
|
664
|
+
const job = getJobById(id);
|
|
665
|
+
if (!job) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
if (job.status !== 'queued') {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
job.status = 'cancelled';
|
|
672
|
+
job.finishedAt = nowIso();
|
|
673
|
+
job.durationMs = 0;
|
|
674
|
+
const queueIndex = jobQueue.indexOf(id);
|
|
675
|
+
if (queueIndex >= 0) {
|
|
676
|
+
jobQueue.splice(queueIndex, 1);
|
|
677
|
+
}
|
|
678
|
+
pushEvent('job-cancelled', 'Job cancelled', { id });
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
async function executeJob(job) {
|
|
682
|
+
if (job.type === 'open_url') {
|
|
683
|
+
const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
|
|
684
|
+
if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
|
|
685
|
+
throw new Error('Job open_url requires valid url');
|
|
686
|
+
}
|
|
687
|
+
const waitForReadyMs = clampInt(job.input.waitForReadyMs, 1000, 200, 10000);
|
|
688
|
+
return (0, adb_js_1.openUrlInChrome)(urlValue, typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined, {
|
|
689
|
+
waitForReadyMs,
|
|
690
|
+
fallbackToDefault: true,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (job.type === 'snapshot_suite') {
|
|
694
|
+
return getSnapshotSuite(typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
|
|
695
|
+
}
|
|
696
|
+
if (job.type === 'stress_run') {
|
|
697
|
+
return runStressScenario(job.input);
|
|
698
|
+
}
|
|
699
|
+
const workflowName = typeof job.input.name === 'string' ? job.input.name : '';
|
|
700
|
+
if (!workflowName) {
|
|
701
|
+
throw new Error('Job workflow_run requires workflow name');
|
|
702
|
+
}
|
|
703
|
+
return await runWorkflow(workflowName, {
|
|
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
|
+
});
|
|
708
|
+
}
|
|
709
|
+
async function runJobQueue() {
|
|
710
|
+
if (jobRunnerActive) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
jobRunnerActive = true;
|
|
714
|
+
while (jobQueue.length > 0) {
|
|
715
|
+
const jobId = jobQueue.shift();
|
|
716
|
+
if (!jobId) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const job = getJobById(jobId);
|
|
720
|
+
if (!job || job.status !== 'queued') {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
job.status = 'running';
|
|
724
|
+
job.startedAt = nowIso();
|
|
725
|
+
pushEvent('job-running', 'Job started', { id: job.id, type: job.type });
|
|
726
|
+
const startedAtMs = Date.now();
|
|
727
|
+
try {
|
|
728
|
+
const result = await executeJob(job);
|
|
729
|
+
job.result = result;
|
|
730
|
+
job.status = 'completed';
|
|
731
|
+
job.finishedAt = nowIso();
|
|
732
|
+
job.durationMs = Date.now() - startedAtMs;
|
|
733
|
+
pushEvent('job-completed', 'Job completed', {
|
|
734
|
+
id: job.id,
|
|
735
|
+
type: job.type,
|
|
736
|
+
durationMs: job.durationMs,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
741
|
+
job.status = 'failed';
|
|
742
|
+
job.error = message;
|
|
743
|
+
job.finishedAt = nowIso();
|
|
744
|
+
job.durationMs = Date.now() - startedAtMs;
|
|
745
|
+
pushEvent('job-failed', 'Job failed', {
|
|
746
|
+
id: job.id,
|
|
747
|
+
type: job.type,
|
|
748
|
+
error: message,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
jobRunnerActive = false;
|
|
753
|
+
}
|
|
754
|
+
function listJobSummaries() {
|
|
755
|
+
return jobs
|
|
756
|
+
.slice()
|
|
757
|
+
.sort((a, b) => b.id - a.id)
|
|
758
|
+
.map(job => ({
|
|
759
|
+
id: job.id,
|
|
760
|
+
type: job.type,
|
|
761
|
+
status: job.status,
|
|
762
|
+
createdAt: job.createdAt,
|
|
763
|
+
startedAt: job.startedAt,
|
|
764
|
+
finishedAt: job.finishedAt,
|
|
765
|
+
durationMs: job.durationMs,
|
|
766
|
+
error: job.error,
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
551
769
|
async function readJsonBody(request) {
|
|
552
770
|
return await new Promise((resolve, reject) => {
|
|
553
771
|
const chunks = [];
|
|
@@ -639,23 +857,25 @@ function buildStatePayload(host, port) {
|
|
|
639
857
|
connectedDeviceCount: devices.length,
|
|
640
858
|
eventCount: eventHistory.length,
|
|
641
859
|
workflowCount: Object.keys(workflows).length,
|
|
860
|
+
queueDepth: jobQueue.length,
|
|
861
|
+
jobCount: jobs.length,
|
|
642
862
|
snapshotKinds: SNAPSHOT_KINDS,
|
|
643
863
|
};
|
|
644
864
|
}
|
|
645
865
|
function buildMetricsPayload() {
|
|
646
|
-
const
|
|
866
|
+
const entries = Object.values(metrics)
|
|
647
867
|
.sort((a, b) => b.count - a.count)
|
|
648
|
-
.map(
|
|
649
|
-
...
|
|
650
|
-
avgDurationMs:
|
|
651
|
-
successRate:
|
|
868
|
+
.map(entry => ({
|
|
869
|
+
...entry,
|
|
870
|
+
avgDurationMs: entry.count > 0 ? Math.round((entry.totalDurationMs / entry.count) * 100) / 100 : 0,
|
|
871
|
+
successRate: entry.count > 0 ? Math.round((entry.success / entry.count) * 10000) / 100 : 0,
|
|
652
872
|
}));
|
|
653
873
|
return {
|
|
654
874
|
generatedAt: nowIso(),
|
|
655
875
|
uptimeMs: Date.now() - serverStartedAt,
|
|
656
|
-
totalActions:
|
|
657
|
-
errors:
|
|
658
|
-
entries
|
|
876
|
+
totalActions: entries.reduce((sum, item) => sum + item.count, 0),
|
|
877
|
+
errors: entries.reduce((sum, item) => sum + item.errors, 0),
|
|
878
|
+
entries,
|
|
659
879
|
};
|
|
660
880
|
}
|
|
661
881
|
async function handleApi(request, response, context) {
|
|
@@ -714,6 +934,59 @@ async function handleApi(request, response, context) {
|
|
|
714
934
|
});
|
|
715
935
|
return;
|
|
716
936
|
}
|
|
937
|
+
if (method === 'GET' && pathname === '/api/jobs') {
|
|
938
|
+
await withMetric('jobs-list', () => {
|
|
939
|
+
sendJson(response, 200, {
|
|
940
|
+
jobs: listJobSummaries(),
|
|
941
|
+
queueDepth: jobQueue.length,
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (method === 'POST' && pathname === '/api/jobs') {
|
|
947
|
+
await withMetric('jobs-create', async () => {
|
|
948
|
+
const body = await readJsonBody(request);
|
|
949
|
+
const typeValue = typeof body.type === 'string' ? body.type : '';
|
|
950
|
+
if (!isJobType(typeValue)) {
|
|
951
|
+
sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run' });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
|
|
955
|
+
? body.input
|
|
956
|
+
: {};
|
|
957
|
+
const job = createJob(typeValue, input);
|
|
958
|
+
sendJson(response, 200, {
|
|
959
|
+
ok: true,
|
|
960
|
+
job,
|
|
961
|
+
queueDepth: jobQueue.length,
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (method === 'GET' && /^\/api\/jobs\/\d+$/.test(pathname)) {
|
|
967
|
+
await withMetric('jobs-get', () => {
|
|
968
|
+
const id = Number(pathname.slice('/api/jobs/'.length));
|
|
969
|
+
const job = getJobById(id);
|
|
970
|
+
if (!job) {
|
|
971
|
+
sendJson(response, 404, { error: `job ${id} not found` });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
sendJson(response, 200, { ok: true, job });
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (method === 'POST' && /^\/api\/jobs\/\d+\/cancel$/.test(pathname)) {
|
|
979
|
+
await withMetric('jobs-cancel', () => {
|
|
980
|
+
const id = Number(pathname.split('/')[3]);
|
|
981
|
+
const ok = cancelQueuedJob(id);
|
|
982
|
+
if (!ok) {
|
|
983
|
+
sendJson(response, 400, { error: `job ${id} cannot be cancelled` });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
sendJson(response, 200, { ok: true, id });
|
|
987
|
+
});
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
717
990
|
if (method === 'GET' && pathname === '/api/workflows') {
|
|
718
991
|
await withMetric('workflows-list', () => {
|
|
719
992
|
sendJson(response, 200, {
|
|
@@ -723,6 +996,73 @@ async function handleApi(request, response, context) {
|
|
|
723
996
|
});
|
|
724
997
|
return;
|
|
725
998
|
}
|
|
999
|
+
if (method === 'GET' && pathname === '/api/workflows/export') {
|
|
1000
|
+
await withMetric('workflows-export', () => {
|
|
1001
|
+
sendJson(response, 200, {
|
|
1002
|
+
exportedAt: nowIso(),
|
|
1003
|
+
workflows,
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (method === 'POST' && pathname === '/api/workflows/import') {
|
|
1009
|
+
await withMetric('workflows-import', async () => {
|
|
1010
|
+
const body = await readJsonBody(request);
|
|
1011
|
+
const replace = body.replace === true;
|
|
1012
|
+
const payload = body.workflows;
|
|
1013
|
+
const imported = replace ? {} : { ...workflows };
|
|
1014
|
+
const consumeDefinition = (value) => {
|
|
1015
|
+
if (!value || typeof value !== 'object') {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const item = value;
|
|
1019
|
+
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
1020
|
+
if (!name) {
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
|
|
1024
|
+
const steps = [];
|
|
1025
|
+
for (const stepValue of stepsRaw) {
|
|
1026
|
+
steps.push(normalizeWorkflowStep(stepValue));
|
|
1027
|
+
}
|
|
1028
|
+
if (steps.length === 0) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
imported[name] = {
|
|
1032
|
+
name,
|
|
1033
|
+
description: typeof item.description === 'string' ? item.description : undefined,
|
|
1034
|
+
updatedAt: nowIso(),
|
|
1035
|
+
steps,
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
if (Array.isArray(payload)) {
|
|
1039
|
+
for (const value of payload) {
|
|
1040
|
+
consumeDefinition(value);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
else if (payload && typeof payload === 'object') {
|
|
1044
|
+
for (const value of Object.values(payload)) {
|
|
1045
|
+
consumeDefinition(value);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
sendJson(response, 400, { error: 'workflows must be array or object' });
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
workflows = imported;
|
|
1053
|
+
saveWorkflows();
|
|
1054
|
+
pushEvent('workflow-import', 'Workflows imported', {
|
|
1055
|
+
count: Object.keys(workflows).length,
|
|
1056
|
+
replace,
|
|
1057
|
+
});
|
|
1058
|
+
sendJson(response, 200, {
|
|
1059
|
+
ok: true,
|
|
1060
|
+
count: Object.keys(workflows).length,
|
|
1061
|
+
workflows: workflowsList(),
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
726
1066
|
if (method === 'POST' && pathname === '/api/workflows') {
|
|
727
1067
|
await withMetric('workflows-save', async () => {
|
|
728
1068
|
const body = await readJsonBody(request);
|
|
@@ -738,13 +1078,12 @@ async function handleApi(request, response, context) {
|
|
|
738
1078
|
return;
|
|
739
1079
|
}
|
|
740
1080
|
const steps = stepsRaw.map(step => normalizeWorkflowStep(step));
|
|
741
|
-
|
|
1081
|
+
workflows[name] = {
|
|
742
1082
|
name,
|
|
743
1083
|
description,
|
|
744
1084
|
updatedAt: nowIso(),
|
|
745
1085
|
steps,
|
|
746
1086
|
};
|
|
747
|
-
workflows[name] = workflow;
|
|
748
1087
|
saveWorkflows();
|
|
749
1088
|
pushEvent('workflow-saved', 'Workflow saved', {
|
|
750
1089
|
name,
|
|
@@ -752,7 +1091,7 @@ async function handleApi(request, response, context) {
|
|
|
752
1091
|
});
|
|
753
1092
|
sendJson(response, 200, {
|
|
754
1093
|
ok: true,
|
|
755
|
-
workflow,
|
|
1094
|
+
workflow: workflows[name],
|
|
756
1095
|
});
|
|
757
1096
|
});
|
|
758
1097
|
return;
|
|
@@ -804,11 +1143,9 @@ async function handleApi(request, response, context) {
|
|
|
804
1143
|
sendJson(response, 400, { error: 'url must be valid http/https URL' });
|
|
805
1144
|
return;
|
|
806
1145
|
}
|
|
807
|
-
const deviceId = extractDeviceId(body);
|
|
808
|
-
const waitForReadyMs = clampInt(body.waitForReadyMs, 1200, 200, 10000);
|
|
809
1146
|
const startedAt = Date.now();
|
|
810
|
-
const result = (0, adb_js_1.openUrlInChrome)(targetUrl,
|
|
811
|
-
waitForReadyMs,
|
|
1147
|
+
const result = (0, adb_js_1.openUrlInChrome)(targetUrl, extractDeviceId(body), {
|
|
1148
|
+
waitForReadyMs: clampInt(body.waitForReadyMs, 1000, 200, 10000),
|
|
812
1149
|
fallbackToDefault: true,
|
|
813
1150
|
});
|
|
814
1151
|
pushEvent('open-url', 'Opened URL on device', {
|
|
@@ -826,6 +1163,22 @@ async function handleApi(request, response, context) {
|
|
|
826
1163
|
});
|
|
827
1164
|
return;
|
|
828
1165
|
}
|
|
1166
|
+
if (method === 'POST' && pathname === '/api/device/profile') {
|
|
1167
|
+
await withMetric('device-profile', async () => {
|
|
1168
|
+
const body = await readJsonBody(request);
|
|
1169
|
+
const profile = buildDeviceProfile(extractDeviceId(body), typeof body.packageName === 'string' ? body.packageName : undefined);
|
|
1170
|
+
pushEvent('device-profile', 'Device profile captured', {
|
|
1171
|
+
deviceId: profile.deviceId,
|
|
1172
|
+
durationMs: profile.durationMs,
|
|
1173
|
+
});
|
|
1174
|
+
sendJson(response, 200, {
|
|
1175
|
+
ok: true,
|
|
1176
|
+
profile,
|
|
1177
|
+
updateHint: UPDATE_HINT,
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
829
1182
|
if (method === 'POST' && pathname === '/api/snapshot-suite') {
|
|
830
1183
|
await withMetric('snapshot-suite', async () => {
|
|
831
1184
|
const body = await readJsonBody(request);
|
|
@@ -855,10 +1208,10 @@ async function handleApi(request, response, context) {
|
|
|
855
1208
|
packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
|
|
856
1209
|
includeRaw: true,
|
|
857
1210
|
});
|
|
858
|
-
const
|
|
859
|
-
snapshotCache[kindValue] =
|
|
860
|
-
const diff = diffSnapshot(prev,
|
|
861
|
-
pushEvent('snapshot-diff', '
|
|
1211
|
+
const raw = fresh.raw;
|
|
1212
|
+
snapshotCache[kindValue] = raw;
|
|
1213
|
+
const diff = diffSnapshot(prev, raw);
|
|
1214
|
+
pushEvent('snapshot-diff', 'Snapshot diff captured', {
|
|
862
1215
|
kind: kindValue,
|
|
863
1216
|
changedCount: diff.changedCount,
|
|
864
1217
|
});
|
|
@@ -886,7 +1239,7 @@ async function handleApi(request, response, context) {
|
|
|
886
1239
|
packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
|
|
887
1240
|
includeRaw: body.includeRaw === true,
|
|
888
1241
|
});
|
|
889
|
-
pushEvent('snapshot', '
|
|
1242
|
+
pushEvent('snapshot', 'Snapshot captured', {
|
|
890
1243
|
kind: kindValue,
|
|
891
1244
|
deviceId: result.deviceId,
|
|
892
1245
|
});
|
|
@@ -902,7 +1255,7 @@ async function handleApi(request, response, context) {
|
|
|
902
1255
|
await withMetric('stress-run', async () => {
|
|
903
1256
|
const body = await readJsonBody(request);
|
|
904
1257
|
const result = runStressScenario(body);
|
|
905
|
-
pushEvent('stress-run', '
|
|
1258
|
+
pushEvent('stress-run', 'Stress run completed', {
|
|
906
1259
|
totalSteps: result.totalSteps,
|
|
907
1260
|
durationMs: result.durationMs,
|
|
908
1261
|
});
|
|
@@ -917,7 +1270,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
917
1270
|
<head>
|
|
918
1271
|
<meta charset="utf-8" />
|
|
919
1272
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
920
|
-
<title>the-android-mcp web ui v3.
|
|
1273
|
+
<title>the-android-mcp web ui v3.3</title>
|
|
921
1274
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
922
1275
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
923
1276
|
<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 +1301,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
948
1301
|
linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
|
|
949
1302
|
padding: 16px;
|
|
950
1303
|
}
|
|
951
|
-
.app { max-width:
|
|
1304
|
+
.app { max-width: 1500px; margin: 0 auto; display: grid; gap: 12px; }
|
|
952
1305
|
.hero {
|
|
953
1306
|
border: 1px solid var(--line);
|
|
954
1307
|
border-radius: 18px;
|
|
@@ -978,7 +1331,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
978
1331
|
.grid {
|
|
979
1332
|
display: grid;
|
|
980
1333
|
gap: 10px;
|
|
981
|
-
grid-template-columns:
|
|
1334
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
982
1335
|
}
|
|
983
1336
|
.card {
|
|
984
1337
|
border: 1px solid var(--line);
|
|
@@ -1000,13 +1353,13 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1000
1353
|
padding: 9px;
|
|
1001
1354
|
font: inherit;
|
|
1002
1355
|
}
|
|
1003
|
-
textarea { min-height:
|
|
1356
|
+
textarea { min-height: 90px; resize: vertical; }
|
|
1004
1357
|
button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
|
|
1005
1358
|
button:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
|
1006
1359
|
.p { background: linear-gradient(130deg, #0d7666, #155f75); }
|
|
1007
1360
|
.s { background: linear-gradient(130deg, #1d3348, #192b3c); }
|
|
1008
1361
|
.w { background: linear-gradient(130deg, #7a5917, #61401f); }
|
|
1009
|
-
.events, .metrics {
|
|
1362
|
+
.events, .metrics, .jobs {
|
|
1010
1363
|
max-height: 300px;
|
|
1011
1364
|
overflow: auto;
|
|
1012
1365
|
border: 1px solid #2f4a61;
|
|
@@ -1014,7 +1367,7 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1014
1367
|
padding: 8px;
|
|
1015
1368
|
background: #0b141d;
|
|
1016
1369
|
}
|
|
1017
|
-
.
|
|
1370
|
+
.item {
|
|
1018
1371
|
border: 1px solid #2e4a61;
|
|
1019
1372
|
border-radius: 8px;
|
|
1020
1373
|
padding: 6px;
|
|
@@ -1058,15 +1411,16 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1058
1411
|
<span class="pill" id="version-pill">v3</span>
|
|
1059
1412
|
<span class="pill" id="device-pill">device: n/a</span>
|
|
1060
1413
|
<span class="pill" id="workflow-pill">workflows: 0</span>
|
|
1414
|
+
<span class="pill" id="queue-pill">queue: 0</span>
|
|
1061
1415
|
<span class="pill">port: 50000</span>
|
|
1062
1416
|
</div>
|
|
1063
|
-
<h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.
|
|
1064
|
-
<p class="muted">
|
|
1417
|
+
<h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.3 command center</h1>
|
|
1418
|
+
<p class="muted">Upgraded backend integration with job queue, workflow engine, snapshot diff, metrics, and live events.</p>
|
|
1065
1419
|
</section>
|
|
1066
1420
|
|
|
1067
1421
|
<section class="grid">
|
|
1068
1422
|
<article class="card stack">
|
|
1069
|
-
<h2>Device
|
|
1423
|
+
<h2>Device operations</h2>
|
|
1070
1424
|
<select id="device-select"></select>
|
|
1071
1425
|
<input id="url-input" type="url" value="https://www.wikipedia.org" />
|
|
1072
1426
|
<div class="split">
|
|
@@ -1079,6 +1433,17 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1079
1433
|
<button class="s quick-url" data-url="https://developer.android.com">Android Docs</button>
|
|
1080
1434
|
<button class="s quick-url" data-url="https://www.youtube.com">YouTube</button>
|
|
1081
1435
|
</div>
|
|
1436
|
+
<div class="split">
|
|
1437
|
+
<button class="s" id="profile-btn">Device profile</button>
|
|
1438
|
+
<button class="w" id="stress-btn">Run stress</button>
|
|
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" />
|
|
1446
|
+
</div>
|
|
1082
1447
|
<p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
|
|
1083
1448
|
</article>
|
|
1084
1449
|
|
|
@@ -1095,37 +1460,41 @@ const INDEX_HTML = String.raw `<!doctype html>
|
|
|
1095
1460
|
<button class="s" id="snapshot-btn">Capture</button>
|
|
1096
1461
|
<button class="w" id="snapshot-diff-btn">Capture diff</button>
|
|
1097
1462
|
</div>
|
|
1098
|
-
<p class="muted">Diff compares latest capture with previous capture of same type.</p>
|
|
1099
|
-
</article>
|
|
1100
1463
|
|
|
1101
|
-
|
|
1102
|
-
<h2>Stress run</h2>
|
|
1103
|
-
<textarea id="stress-urls">https://www.wikipedia.org
|
|
1104
|
-
https://news.ycombinator.com
|
|
1105
|
-
https://developer.android.com</textarea>
|
|
1106
|
-
<div class="split">
|
|
1107
|
-
<input id="stress-loops" type="number" min="1" max="5" value="1" />
|
|
1108
|
-
<input id="stress-wait" type="number" min="200" max="6000" value="1000" />
|
|
1109
|
-
</div>
|
|
1110
|
-
<button class="w" id="stress-btn">Run stress</button>
|
|
1111
|
-
<p class="muted">Config: loops / wait-ms</p>
|
|
1112
|
-
</article>
|
|
1113
|
-
|
|
1114
|
-
<article class="card stack">
|
|
1115
|
-
<h2>Workflow engine</h2>
|
|
1464
|
+
<h2 style="margin-top:8px;">Workflow engine</h2>
|
|
1116
1465
|
<select id="workflow-select"></select>
|
|
1117
1466
|
<input id="workflow-name" placeholder="workflow name" value="" />
|
|
1118
1467
|
<textarea id="workflow-steps">[
|
|
1119
1468
|
{"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":1000},
|
|
1120
1469
|
{"type":"snapshot","snapshot":"radio"},
|
|
1121
|
-
{"type":"open_url","url":"https://developer.android.com","waitForReadyMs":1000},
|
|
1122
1470
|
{"type":"snapshot_suite","packageName":"com.android.chrome"}
|
|
1123
1471
|
]</textarea>
|
|
1124
1472
|
<div class="split">
|
|
1125
1473
|
<button class="p" id="workflow-save-btn">Save</button>
|
|
1126
1474
|
<button class="p" id="workflow-run-btn">Run</button>
|
|
1127
1475
|
</div>
|
|
1128
|
-
<
|
|
1476
|
+
<div class="split">
|
|
1477
|
+
<button class="s" id="workflow-delete-btn">Delete</button>
|
|
1478
|
+
<button class="s" id="workflow-export-btn">Export</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
<button class="s" id="workflow-import-btn">Import from editor</button>
|
|
1481
|
+
</article>
|
|
1482
|
+
|
|
1483
|
+
<article class="card stack">
|
|
1484
|
+
<h2>Job orchestrator</h2>
|
|
1485
|
+
<select id="job-type">
|
|
1486
|
+
<option value="open_url">open_url</option>
|
|
1487
|
+
<option value="snapshot_suite">snapshot_suite</option>
|
|
1488
|
+
<option value="stress_run">stress_run</option>
|
|
1489
|
+
<option value="workflow_run">workflow_run</option>
|
|
1490
|
+
</select>
|
|
1491
|
+
<input id="job-url" type="url" value="https://developer.android.com" />
|
|
1492
|
+
<select id="job-workflow"></select>
|
|
1493
|
+
<div class="split">
|
|
1494
|
+
<button class="p" id="job-enqueue-btn">Enqueue job</button>
|
|
1495
|
+
<button class="s" id="job-refresh-btn">Refresh jobs</button>
|
|
1496
|
+
</div>
|
|
1497
|
+
<div id="jobs" class="jobs"></div>
|
|
1129
1498
|
</article>
|
|
1130
1499
|
|
|
1131
1500
|
<article class="card stack">
|
|
@@ -1155,18 +1524,21 @@ https://developer.android.com</textarea>
|
|
|
1155
1524
|
const state = {
|
|
1156
1525
|
deviceId: undefined,
|
|
1157
1526
|
workflows: [],
|
|
1527
|
+
jobs: [],
|
|
1158
1528
|
};
|
|
1159
1529
|
|
|
1160
1530
|
const $status = document.getElementById('status');
|
|
1161
1531
|
const $versionPill = document.getElementById('version-pill');
|
|
1162
1532
|
const $devicePill = document.getElementById('device-pill');
|
|
1163
1533
|
const $workflowPill = document.getElementById('workflow-pill');
|
|
1534
|
+
const $queuePill = document.getElementById('queue-pill');
|
|
1164
1535
|
const $deviceSelect = document.getElementById('device-select');
|
|
1165
1536
|
const $urlInput = document.getElementById('url-input');
|
|
1166
1537
|
const $message = document.getElementById('message');
|
|
1167
1538
|
const $output = document.getElementById('output');
|
|
1168
1539
|
const $events = document.getElementById('events');
|
|
1169
1540
|
const $metrics = document.getElementById('metrics');
|
|
1541
|
+
const $jobs = document.getElementById('jobs');
|
|
1170
1542
|
const $snapshotKind = document.getElementById('snapshot-kind');
|
|
1171
1543
|
const $stressUrls = document.getElementById('stress-urls');
|
|
1172
1544
|
const $stressLoops = document.getElementById('stress-loops');
|
|
@@ -1174,6 +1546,9 @@ https://developer.android.com</textarea>
|
|
|
1174
1546
|
const $workflowSelect = document.getElementById('workflow-select');
|
|
1175
1547
|
const $workflowName = document.getElementById('workflow-name');
|
|
1176
1548
|
const $workflowSteps = document.getElementById('workflow-steps');
|
|
1549
|
+
const $jobType = document.getElementById('job-type');
|
|
1550
|
+
const $jobUrl = document.getElementById('job-url');
|
|
1551
|
+
const $jobWorkflow = document.getElementById('job-workflow');
|
|
1177
1552
|
|
|
1178
1553
|
function setMessage(text, isError) {
|
|
1179
1554
|
$message.textContent = text;
|
|
@@ -1190,14 +1565,14 @@ https://developer.android.com</textarea>
|
|
|
1190
1565
|
return;
|
|
1191
1566
|
}
|
|
1192
1567
|
const item = document.createElement('div');
|
|
1193
|
-
item.className = '
|
|
1568
|
+
item.className = 'item';
|
|
1194
1569
|
const meta = document.createElement('div');
|
|
1195
1570
|
meta.className = 'meta';
|
|
1196
1571
|
meta.textContent = '[' + (event.at || new Date().toISOString()) + '] ' + event.type;
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1572
|
+
const text = document.createElement('div');
|
|
1573
|
+
text.textContent = event.message || '';
|
|
1199
1574
|
item.appendChild(meta);
|
|
1200
|
-
item.appendChild(
|
|
1575
|
+
item.appendChild(text);
|
|
1201
1576
|
if (event.data) {
|
|
1202
1577
|
const data = document.createElement('div');
|
|
1203
1578
|
data.style.color = '#a5c6db';
|
|
@@ -1206,7 +1581,7 @@ https://developer.android.com</textarea>
|
|
|
1206
1581
|
item.appendChild(data);
|
|
1207
1582
|
}
|
|
1208
1583
|
$events.prepend(item);
|
|
1209
|
-
while ($events.children.length >
|
|
1584
|
+
while ($events.children.length > 120) {
|
|
1210
1585
|
$events.removeChild($events.lastChild);
|
|
1211
1586
|
}
|
|
1212
1587
|
}
|
|
@@ -1214,9 +1589,9 @@ https://developer.android.com</textarea>
|
|
|
1214
1589
|
function renderMetrics(payload) {
|
|
1215
1590
|
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
1216
1591
|
$metrics.innerHTML = '';
|
|
1217
|
-
for (const entry of entries.slice(0,
|
|
1592
|
+
for (const entry of entries.slice(0, 30)) {
|
|
1218
1593
|
const item = document.createElement('div');
|
|
1219
|
-
item.className = '
|
|
1594
|
+
item.className = 'item';
|
|
1220
1595
|
item.innerHTML =
|
|
1221
1596
|
'<div class="meta">' + entry.name + '</div>' +
|
|
1222
1597
|
'<div>count=' + entry.count + ' success=' + entry.success + ' errors=' + entry.errors + '</div>' +
|
|
@@ -1228,6 +1603,42 @@ https://developer.android.com</textarea>
|
|
|
1228
1603
|
}
|
|
1229
1604
|
}
|
|
1230
1605
|
|
|
1606
|
+
function renderJobs(payload) {
|
|
1607
|
+
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
|
1608
|
+
state.jobs = jobs;
|
|
1609
|
+
$jobs.innerHTML = '';
|
|
1610
|
+
for (const job of jobs.slice(0, 40)) {
|
|
1611
|
+
const item = document.createElement('div');
|
|
1612
|
+
item.className = 'item';
|
|
1613
|
+
item.innerHTML =
|
|
1614
|
+
'<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
|
|
1615
|
+
'<div>created=' + (job.createdAt || '-') + '</div>' +
|
|
1616
|
+
'<div>duration=' + (job.durationMs || 0) + 'ms</div>' +
|
|
1617
|
+
(job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
|
|
1618
|
+
if (job.status === 'queued') {
|
|
1619
|
+
const cancel = document.createElement('button');
|
|
1620
|
+
cancel.className = 'w';
|
|
1621
|
+
cancel.textContent = 'Cancel';
|
|
1622
|
+
cancel.style.marginTop = '6px';
|
|
1623
|
+
cancel.addEventListener('click', async function () {
|
|
1624
|
+
try {
|
|
1625
|
+
const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
|
|
1626
|
+
renderOutput(result);
|
|
1627
|
+
await refreshJobs();
|
|
1628
|
+
setMessage('Job cancelled', false);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
setMessage(String(error), true);
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
item.appendChild(cancel);
|
|
1634
|
+
}
|
|
1635
|
+
$jobs.appendChild(item);
|
|
1636
|
+
}
|
|
1637
|
+
if (!jobs.length) {
|
|
1638
|
+
$jobs.textContent = 'No jobs yet.';
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1231
1642
|
async function api(path, method, body) {
|
|
1232
1643
|
const response = await fetch(path, {
|
|
1233
1644
|
method: method || 'GET',
|
|
@@ -1245,14 +1656,39 @@ https://developer.android.com</textarea>
|
|
|
1245
1656
|
return state.deviceId || undefined;
|
|
1246
1657
|
}
|
|
1247
1658
|
|
|
1248
|
-
|
|
1659
|
+
function selectedWorkflowName() {
|
|
1660
|
+
const value = ($workflowSelect.value || '').trim();
|
|
1661
|
+
return value || undefined;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function currentStressConfig() {
|
|
1665
|
+
return {
|
|
1666
|
+
urls: $stressUrls.value
|
|
1667
|
+
.split('\n')
|
|
1668
|
+
.map(function (line) { return line.trim(); })
|
|
1669
|
+
.filter(function (line) { return line.length > 0; }),
|
|
1670
|
+
loops: Number($stressLoops.value || '1'),
|
|
1671
|
+
waitForReadyMs: Number($stressWait.value || '1000'),
|
|
1672
|
+
includeSnapshotAfterEach: true,
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function refreshJobs() {
|
|
1677
|
+
const payload = await api('/api/jobs');
|
|
1678
|
+
renderJobs(payload);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function refreshCore() {
|
|
1249
1682
|
const statePayload = await api('/api/state');
|
|
1250
1683
|
$status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
|
|
1251
1684
|
$versionPill.textContent = 'v' + statePayload.version;
|
|
1252
1685
|
$workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
|
|
1686
|
+
$queuePill.textContent = 'queue: ' + statePayload.queueDepth;
|
|
1253
1687
|
|
|
1254
1688
|
const devicesPayload = await api('/api/devices');
|
|
1255
1689
|
const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
|
|
1690
|
+
const previousDevice = state.deviceId;
|
|
1691
|
+
|
|
1256
1692
|
$deviceSelect.innerHTML = '';
|
|
1257
1693
|
for (const device of devices) {
|
|
1258
1694
|
const option = document.createElement('option');
|
|
@@ -1260,7 +1696,14 @@ https://developer.android.com</textarea>
|
|
|
1260
1696
|
option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
|
|
1261
1697
|
$deviceSelect.appendChild(option);
|
|
1262
1698
|
}
|
|
1263
|
-
|
|
1699
|
+
|
|
1700
|
+
state.deviceId = undefined;
|
|
1701
|
+
if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
|
|
1702
|
+
state.deviceId = previousDevice;
|
|
1703
|
+
} else if (devices[0]) {
|
|
1704
|
+
state.deviceId = devices[0].id;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1264
1707
|
if (state.deviceId) {
|
|
1265
1708
|
$deviceSelect.value = state.deviceId;
|
|
1266
1709
|
$devicePill.textContent = 'device: ' + state.deviceId;
|
|
@@ -1269,23 +1712,40 @@ https://developer.android.com</textarea>
|
|
|
1269
1712
|
}
|
|
1270
1713
|
|
|
1271
1714
|
const workflowsPayload = await api('/api/workflows');
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1715
|
+
const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
|
|
1716
|
+
state.workflows = workflows;
|
|
1717
|
+
|
|
1718
|
+
const renderWorkflowSelect = function (select) {
|
|
1719
|
+
const prev = (select.value || '').trim();
|
|
1720
|
+
select.innerHTML = '';
|
|
1721
|
+
for (const workflow of workflows) {
|
|
1722
|
+
const option = document.createElement('option');
|
|
1723
|
+
option.value = workflow.name;
|
|
1724
|
+
option.textContent = workflow.name;
|
|
1725
|
+
select.appendChild(option);
|
|
1726
|
+
}
|
|
1727
|
+
if (prev && workflows.some(function (w) { return w.name === prev; })) {
|
|
1728
|
+
select.value = prev;
|
|
1729
|
+
} else if (workflows[0]) {
|
|
1730
|
+
select.value = workflows[0].name;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
renderWorkflowSelect($workflowSelect);
|
|
1735
|
+
renderWorkflowSelect($jobWorkflow);
|
|
1736
|
+
|
|
1737
|
+
if ($workflowSelect.value) {
|
|
1738
|
+
const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
1739
|
+
if (selected) {
|
|
1740
|
+
$workflowName.value = selected.name;
|
|
1741
|
+
$workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
|
|
1742
|
+
}
|
|
1285
1743
|
}
|
|
1286
1744
|
|
|
1287
1745
|
const metricsPayload = await api('/api/metrics');
|
|
1288
1746
|
renderMetrics(metricsPayload);
|
|
1747
|
+
|
|
1748
|
+
await refreshJobs();
|
|
1289
1749
|
}
|
|
1290
1750
|
|
|
1291
1751
|
function connectEvents() {
|
|
@@ -1310,6 +1770,16 @@ https://developer.android.com</textarea>
|
|
|
1310
1770
|
setMessage('URL opened', false);
|
|
1311
1771
|
}
|
|
1312
1772
|
|
|
1773
|
+
async function runSuite() {
|
|
1774
|
+
setMessage('Running suite', false);
|
|
1775
|
+
const result = await api('/api/snapshot-suite', 'POST', {
|
|
1776
|
+
deviceId: selectedDeviceId(),
|
|
1777
|
+
packageName: 'com.android.chrome',
|
|
1778
|
+
});
|
|
1779
|
+
renderOutput(result);
|
|
1780
|
+
setMessage('Suite completed', false);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1313
1783
|
async function captureSnapshot() {
|
|
1314
1784
|
const kind = $snapshotKind.value;
|
|
1315
1785
|
setMessage('Capturing snapshot: ' + kind, false);
|
|
@@ -1324,44 +1794,38 @@ https://developer.android.com</textarea>
|
|
|
1324
1794
|
|
|
1325
1795
|
async function captureSnapshotDiff() {
|
|
1326
1796
|
const kind = $snapshotKind.value;
|
|
1327
|
-
setMessage('Capturing
|
|
1797
|
+
setMessage('Capturing diff: ' + kind, false);
|
|
1328
1798
|
const result = await api('/api/snapshot/diff', 'POST', {
|
|
1329
1799
|
deviceId: selectedDeviceId(),
|
|
1330
1800
|
packageName: 'com.android.chrome',
|
|
1331
1801
|
kind: kind,
|
|
1332
1802
|
});
|
|
1333
1803
|
renderOutput(result);
|
|
1334
|
-
setMessage('Snapshot diff
|
|
1804
|
+
setMessage('Snapshot diff completed', false);
|
|
1335
1805
|
}
|
|
1336
1806
|
|
|
1337
|
-
async function
|
|
1338
|
-
setMessage('
|
|
1339
|
-
const result = await api('/api/
|
|
1807
|
+
async function captureDeviceProfile() {
|
|
1808
|
+
setMessage('Capturing device profile', false);
|
|
1809
|
+
const result = await api('/api/device/profile', 'POST', {
|
|
1340
1810
|
deviceId: selectedDeviceId(),
|
|
1341
1811
|
packageName: 'com.android.chrome',
|
|
1342
1812
|
});
|
|
1343
1813
|
renderOutput(result);
|
|
1344
|
-
setMessage('
|
|
1814
|
+
setMessage('Device profile ready', false);
|
|
1345
1815
|
}
|
|
1346
1816
|
|
|
1347
1817
|
async function runStress() {
|
|
1348
1818
|
setMessage('Running stress', false);
|
|
1349
|
-
const
|
|
1350
|
-
.split('\n')
|
|
1351
|
-
.map(function (line) { return line.trim(); })
|
|
1352
|
-
.filter(function (line) { return line.length > 0; });
|
|
1353
|
-
const loops = Number($stressLoops.value || '1');
|
|
1354
|
-
const waitForReadyMs = Number($stressWait.value || '1000');
|
|
1355
|
-
|
|
1819
|
+
const cfg = currentStressConfig();
|
|
1356
1820
|
const result = await api('/api/stress-run', 'POST', {
|
|
1357
1821
|
deviceId: selectedDeviceId(),
|
|
1358
|
-
urls,
|
|
1359
|
-
loops,
|
|
1360
|
-
waitForReadyMs,
|
|
1822
|
+
urls: cfg.urls,
|
|
1823
|
+
loops: cfg.loops,
|
|
1824
|
+
waitForReadyMs: cfg.waitForReadyMs,
|
|
1361
1825
|
includeSnapshotAfterEach: true,
|
|
1362
1826
|
});
|
|
1363
1827
|
renderOutput(result);
|
|
1364
|
-
setMessage('Stress run
|
|
1828
|
+
setMessage('Stress run complete', false);
|
|
1365
1829
|
}
|
|
1366
1830
|
|
|
1367
1831
|
async function saveWorkflow() {
|
|
@@ -1373,7 +1837,7 @@ https://developer.android.com</textarea>
|
|
|
1373
1837
|
try {
|
|
1374
1838
|
steps = JSON.parse($workflowSteps.value || '[]');
|
|
1375
1839
|
} catch (error) {
|
|
1376
|
-
throw new Error('workflow steps must be valid JSON
|
|
1840
|
+
throw new Error('workflow steps must be valid JSON');
|
|
1377
1841
|
}
|
|
1378
1842
|
const result = await api('/api/workflows', 'POST', {
|
|
1379
1843
|
name,
|
|
@@ -1381,11 +1845,11 @@ https://developer.android.com</textarea>
|
|
|
1381
1845
|
});
|
|
1382
1846
|
renderOutput(result);
|
|
1383
1847
|
setMessage('Workflow saved', false);
|
|
1384
|
-
await
|
|
1848
|
+
await refreshCore();
|
|
1385
1849
|
}
|
|
1386
1850
|
|
|
1387
1851
|
async function runWorkflow() {
|
|
1388
|
-
const name = (
|
|
1852
|
+
const name = selectedWorkflowName();
|
|
1389
1853
|
if (!name) {
|
|
1390
1854
|
throw new Error('select workflow first');
|
|
1391
1855
|
}
|
|
@@ -1396,18 +1860,76 @@ https://developer.android.com</textarea>
|
|
|
1396
1860
|
includeRaw: false,
|
|
1397
1861
|
});
|
|
1398
1862
|
renderOutput(result);
|
|
1399
|
-
setMessage('Workflow
|
|
1863
|
+
setMessage('Workflow executed', false);
|
|
1400
1864
|
}
|
|
1401
1865
|
|
|
1402
1866
|
async function deleteWorkflow() {
|
|
1403
|
-
const name = (
|
|
1867
|
+
const name = selectedWorkflowName();
|
|
1404
1868
|
if (!name) {
|
|
1405
1869
|
throw new Error('select workflow first');
|
|
1406
1870
|
}
|
|
1407
1871
|
const result = await api('/api/workflows/' + encodeURIComponent(name), 'DELETE');
|
|
1408
1872
|
renderOutput(result);
|
|
1409
1873
|
setMessage('Workflow deleted', false);
|
|
1410
|
-
await
|
|
1874
|
+
await refreshCore();
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
async function exportWorkflows() {
|
|
1878
|
+
const result = await api('/api/workflows/export');
|
|
1879
|
+
$workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
|
|
1880
|
+
renderOutput(result);
|
|
1881
|
+
setMessage('Workflows exported into editor', false);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
async function importWorkflows() {
|
|
1885
|
+
let parsed;
|
|
1886
|
+
try {
|
|
1887
|
+
parsed = JSON.parse($workflowSteps.value || '{}');
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
throw new Error('workflow editor content must be valid JSON');
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const result = await api('/api/workflows/import', 'POST', {
|
|
1893
|
+
workflows: parsed,
|
|
1894
|
+
replace: false,
|
|
1895
|
+
});
|
|
1896
|
+
renderOutput(result);
|
|
1897
|
+
setMessage('Workflows imported', false);
|
|
1898
|
+
await refreshCore();
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function enqueueJob() {
|
|
1902
|
+
const type = ($jobType.value || '').trim();
|
|
1903
|
+
const input = { deviceId: selectedDeviceId() };
|
|
1904
|
+
|
|
1905
|
+
if (type === 'open_url') {
|
|
1906
|
+
input.url = $jobUrl.value || 'https://developer.android.com';
|
|
1907
|
+
input.waitForReadyMs = 1000;
|
|
1908
|
+
} else if (type === 'snapshot_suite') {
|
|
1909
|
+
input.packageName = 'com.android.chrome';
|
|
1910
|
+
} else if (type === 'stress_run') {
|
|
1911
|
+
const cfg = currentStressConfig();
|
|
1912
|
+
input.urls = cfg.urls;
|
|
1913
|
+
input.loops = cfg.loops;
|
|
1914
|
+
input.waitForReadyMs = cfg.waitForReadyMs;
|
|
1915
|
+
input.includeSnapshotAfterEach = true;
|
|
1916
|
+
} else if (type === 'workflow_run') {
|
|
1917
|
+
const workflowName = ($jobWorkflow.value || '').trim();
|
|
1918
|
+
if (!workflowName) {
|
|
1919
|
+
throw new Error('select workflow for workflow_run job');
|
|
1920
|
+
}
|
|
1921
|
+
input.name = workflowName;
|
|
1922
|
+
input.packageName = 'com.android.chrome';
|
|
1923
|
+
input.includeRaw = false;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const result = await api('/api/jobs', 'POST', {
|
|
1927
|
+
type,
|
|
1928
|
+
input,
|
|
1929
|
+
});
|
|
1930
|
+
renderOutput(result);
|
|
1931
|
+
setMessage('Job enqueued', false);
|
|
1932
|
+
await refreshJobs();
|
|
1411
1933
|
}
|
|
1412
1934
|
|
|
1413
1935
|
document.getElementById('open-url-btn').addEventListener('click', async function () {
|
|
@@ -1422,6 +1944,9 @@ https://developer.android.com</textarea>
|
|
|
1422
1944
|
document.getElementById('snapshot-diff-btn').addEventListener('click', async function () {
|
|
1423
1945
|
try { await captureSnapshotDiff(); } catch (error) { setMessage(String(error), true); }
|
|
1424
1946
|
});
|
|
1947
|
+
document.getElementById('profile-btn').addEventListener('click', async function () {
|
|
1948
|
+
try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
|
|
1949
|
+
});
|
|
1425
1950
|
document.getElementById('stress-btn').addEventListener('click', async function () {
|
|
1426
1951
|
try { await runStress(); } catch (error) { setMessage(String(error), true); }
|
|
1427
1952
|
});
|
|
@@ -1434,20 +1959,27 @@ https://developer.android.com</textarea>
|
|
|
1434
1959
|
document.getElementById('workflow-delete-btn').addEventListener('click', async function () {
|
|
1435
1960
|
try { await deleteWorkflow(); } catch (error) { setMessage(String(error), true); }
|
|
1436
1961
|
});
|
|
1962
|
+
document.getElementById('workflow-export-btn').addEventListener('click', async function () {
|
|
1963
|
+
try { await exportWorkflows(); } catch (error) { setMessage(String(error), true); }
|
|
1964
|
+
});
|
|
1965
|
+
document.getElementById('workflow-import-btn').addEventListener('click', async function () {
|
|
1966
|
+
try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
|
|
1967
|
+
});
|
|
1968
|
+
document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
|
|
1969
|
+
try { await enqueueJob(); } catch (error) { setMessage(String(error), true); }
|
|
1970
|
+
});
|
|
1971
|
+
document.getElementById('job-refresh-btn').addEventListener('click', async function () {
|
|
1972
|
+
try { await refreshJobs(); setMessage('Jobs refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
1973
|
+
});
|
|
1437
1974
|
document.getElementById('refresh-state-btn').addEventListener('click', async function () {
|
|
1438
|
-
try {
|
|
1439
|
-
await refreshState();
|
|
1440
|
-
setMessage('State refreshed', false);
|
|
1441
|
-
} catch (error) {
|
|
1442
|
-
setMessage(String(error), true);
|
|
1443
|
-
}
|
|
1975
|
+
try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
|
|
1444
1976
|
});
|
|
1445
1977
|
document.getElementById('clear-output-btn').addEventListener('click', function () {
|
|
1446
1978
|
renderOutput({});
|
|
1447
1979
|
});
|
|
1448
1980
|
|
|
1449
1981
|
$deviceSelect.addEventListener('change', function () {
|
|
1450
|
-
state.deviceId = $deviceSelect.value;
|
|
1982
|
+
state.deviceId = $deviceSelect.value || undefined;
|
|
1451
1983
|
$devicePill.textContent = 'device: ' + (state.deviceId || 'none');
|
|
1452
1984
|
});
|
|
1453
1985
|
|
|
@@ -1466,7 +1998,7 @@ https://developer.android.com</textarea>
|
|
|
1466
1998
|
}
|
|
1467
1999
|
|
|
1468
2000
|
$workflowSelect.addEventListener('change', function () {
|
|
1469
|
-
const selected = state.workflows.find(function (
|
|
2001
|
+
const selected = state.workflows.find(function (w) { return w.name === $workflowSelect.value; });
|
|
1470
2002
|
if (selected) {
|
|
1471
2003
|
$workflowName.value = selected.name;
|
|
1472
2004
|
$workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
|
|
@@ -1475,8 +2007,8 @@ https://developer.android.com</textarea>
|
|
|
1475
2007
|
|
|
1476
2008
|
async function init() {
|
|
1477
2009
|
try {
|
|
1478
|
-
await
|
|
1479
|
-
const history = await api('/api/history?limit=
|
|
2010
|
+
await refreshCore();
|
|
2011
|
+
const history = await api('/api/history?limit=40');
|
|
1480
2012
|
const events = Array.isArray(history.events) ? history.events : [];
|
|
1481
2013
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1482
2014
|
addEvent(events[i]);
|
|
@@ -1484,12 +2016,14 @@ https://developer.android.com</textarea>
|
|
|
1484
2016
|
connectEvents();
|
|
1485
2017
|
renderOutput({ ok: true, message: 'UI ready', updateHint: '${UPDATE_HINT}' });
|
|
1486
2018
|
setMessage('Ready.', false);
|
|
2019
|
+
|
|
1487
2020
|
setInterval(async function () {
|
|
1488
2021
|
try {
|
|
1489
2022
|
const metricsPayload = await api('/api/metrics');
|
|
1490
2023
|
renderMetrics(metricsPayload);
|
|
2024
|
+
await refreshJobs();
|
|
1491
2025
|
} catch (error) {
|
|
1492
|
-
setMessage('
|
|
2026
|
+
setMessage('Background refresh failed', true);
|
|
1493
2027
|
}
|
|
1494
2028
|
}, 5000);
|
|
1495
2029
|
} catch (error) {
|