@xenon-device-management/xenon 2.2.0 → 2.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/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-CIU02EIx.js} +1 -1
- package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-_akEEjXm.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
- package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-DRKRm19_.js} +1 -1
- package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-zQQHPh7x.js} +1 -1
- package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-C9_StnDi.js} +1 -1
- package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-0J7io6pF.js} +1 -1
- package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-C677ljNX.js} +1 -1
- package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-BDJFck-L.js} +1 -1
- package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-DQkimbKK.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-DvGMZBIE.js} +1 -1
- package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-ogyouYBq.js} +1 -1
- package/lib/public/assets/{Table-B_Va3V05.js → Table-BLFCOLlO.js} +1 -1
- package/lib/public/assets/{activity-DxRFWvkN.js → activity-BU55TGRG.js} +1 -1
- package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-GpDAiHav.js} +1 -1
- package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-dOzP0k8_.js} +1 -1
- package/lib/public/assets/{apps-CgGXTlgn.js → apps-mAwAQmkn.js} +1 -1
- package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-Dg9mxe9T.js} +1 -1
- package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BVcmUyq3.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-ByR5pZrH.js} +1 -1
- package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-Jpgk8CgY.js} +2 -2
- package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-VnRTrcdl.js} +1 -1
- package/lib/public/assets/{button-_Y78wXiP.js → button-D-X4wjO8.js} +1 -1
- package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-ur_p5ETA.js} +1 -1
- package/lib/public/assets/{check-uGHCXe-L.js → check-K33iMeyq.js} +1 -1
- package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-DMVWShd-.js} +1 -1
- package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-DphLJ2_X.js} +1 -1
- package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-CNFhu8oE.js} +1 -1
- package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-BH_cUbqZ.js} +1 -1
- package/lib/public/assets/{clock-BfYfzvSg.js → clock-CVMVdVvp.js} +1 -1
- package/lib/public/assets/{copy-DZwYE5Va.js → copy-CZ2XHafL.js} +1 -1
- package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-B94BhaqF.js} +52 -52
- package/lib/public/assets/{download-CCDYo_TN.js → download-UUiyw7kO.js} +1 -1
- package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-CrS14F79.js} +1 -1
- package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BMOE5qJY.js} +1 -1
- package/lib/public/assets/{index-3uxl3o7K.js → index-2XPPbPwy.js} +2 -2
- package/lib/public/assets/{index-Zy1Op5C1.js → index-D0R9Yic3.js} +1 -1
- package/lib/public/assets/{input-CzTWvUXZ.js → input-C-1b8J7i.js} +1 -1
- package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-Cly7dQc9.js} +4 -4
- package/lib/public/assets/{layers-YZZGFyTX.js → layers-DFZfqGdX.js} +1 -1
- package/lib/public/assets/{lock-L8tzWgDo.js → lock-ylHMJpfP.js} +1 -1
- package/lib/public/assets/{login-DBbX-fu8.js → login-DWsWKqDx.js} +1 -1
- package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-9B6_MTqW.js} +1 -1
- package/lib/public/assets/{monitor-Di_66F2m.js → monitor-CzX0hBH1.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
- package/lib/public/assets/{network-DiSumY-1.js → network-Bikc4MyT.js} +1 -1
- package/lib/public/assets/{overview-CwE4gXl5.js → overview-DzaA0aMe.js} +1 -1
- package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-DqGlDufy.js} +1 -1
- package/lib/public/assets/{plus-DOS8P9CQ.js → plus-Ct6aQTGM.js} +1 -1
- package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CCnUQ1Qk.js} +1 -1
- package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-AmkGGXo5.js} +1 -1
- package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-DQQn9VyE.js} +1 -1
- package/lib/public/assets/{select-Cqx7pEou.js → select-k5ogtEWW.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BCcwC5ku.js} +1 -1
- package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-BVyX6kIh.js} +1 -1
- package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-CElBB6XT.js} +1 -1
- package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-6bqo_-ND.js} +1 -1
- package/lib/public/assets/{settings-C-s5pypL.js → settings-DKBj9KyN.js} +1 -1
- package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-CZNpvqej.js} +1 -1
- package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-B6HI5OxV.js} +1 -1
- package/lib/public/assets/{teams-ui4IhHNC.js → teams-owoDHbOU.js} +1 -1
- package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-D61PoUrZ.js} +1 -1
- package/lib/public/assets/{upload-DkGy_J-L.js → upload-1ZYEVvgD.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
- package/lib/public/assets/{users-Nb_rzRmF.js → users-C5CS5Qez.js} +1 -1
- package/lib/public/assets/{users-BU0nXIVe.js → users-PbGMd5sD.js} +1 -1
- package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-_XusI0HK.js} +1 -1
- package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-Bi_HHQlQ.js} +1 -1
- package/lib/public/assets/{zap-B2bH2vKX.js → zap-uzHWFAKI.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +114 -61
- package/lib/src/app/routers/nodePhoneControl.js +265 -46
- package/lib/src/data-service/device-store.js +7 -3
- package/lib/src/data-service/deviceClaims.js +14 -0
- package/lib/src/data-service/deviceFieldOwners.js +3 -1
- package/lib/src/data-service/prisma-store.js +3 -3
- package/lib/src/device-managers/AndroidDeviceManager.js +11 -1
- package/lib/src/gateway/forwardToNode.js +10 -3
- package/lib/src/generated/client/edge.js +5 -4
- package/lib/src/generated/client/index-browser.js +2 -1
- package/lib/src/generated/client/index.d.ts +36 -1
- package/lib/src/generated/client/index.js +5 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +4 -0
- package/lib/src/generated/client/wasm.js +2 -1
- package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
- package/lib/test/unit/hub-node-race.spec.js +29 -3
- package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
- package/lib/test/unit/node-phone-control.spec.js +98 -4
- package/lib/test/unit/screenshot-route.spec.js +104 -0
- package/lib/test/unit/upload-install-route.spec.js +205 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
- package/prisma/schema.prisma +4 -0
- package/lib/public/assets/DeviceMosaicView-wl3l2Zlt.js +0 -2
|
@@ -204,6 +204,10 @@ model Device {
|
|
|
204
204
|
// On a hub, what the phone's node last reported: busy there. `busy` is the
|
|
205
205
|
// claim OR this. Always false for a phone this server drives.
|
|
206
206
|
nodeBusy Boolean? @default(false)
|
|
207
|
+
// On a hub, who holds the phone there: the preview hold (manual_<user>_<udid>)
|
|
208
|
+
// in the node's report of its session_id, else null. Shown, never judged:
|
|
209
|
+
// the node decides access. Always null for a phone this server drives.
|
|
210
|
+
nodeHold String?
|
|
207
211
|
|
|
208
212
|
@@id([udid, host])
|
|
209
213
|
@@index([owningSessionId])
|
|
@@ -285,7 +285,8 @@ exports.Prisma.DeviceScalarFieldEnum = {
|
|
|
285
285
|
teamId: 'teamId',
|
|
286
286
|
claimSessionId: 'claimSessionId',
|
|
287
287
|
claimedAt: 'claimedAt',
|
|
288
|
-
nodeBusy: 'nodeBusy'
|
|
288
|
+
nodeBusy: 'nodeBusy',
|
|
289
|
+
nodeHold: 'nodeHold'
|
|
289
290
|
};
|
|
290
291
|
|
|
291
292
|
exports.Prisma.PendingSessionScalarFieldEnum = {
|
|
@@ -166,6 +166,18 @@ describe('Android Device Manager', () => {
|
|
|
166
166
|
(0, chai_1.expect)(devices.length).to.be.equal(1);
|
|
167
167
|
(0, chai_1.expect)(devices[0]).to.have.property('udid', 'emulator-7777');
|
|
168
168
|
}));
|
|
169
|
+
// adb install answers when the phone has installed the app, and appium-adb
|
|
170
|
+
// cut every adbExec at 20 s (adbExecTimeout): an install that took longer
|
|
171
|
+
// was reported as failed though it had worked.
|
|
172
|
+
it('gives adb install as long as an install takes, not adbExec’s 20 s', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
173
|
+
const manager = (0, test_container_1.createTestAndroidManager)({ platform: 'android' });
|
|
174
|
+
const adbExec = sandbox.stub().resolves('Success');
|
|
175
|
+
sandbox.stub(manager, 'getAdb').resolves({ adbInstance: { adbExec } });
|
|
176
|
+
yield manager.installApp('p1', '/tmp/app.apk');
|
|
177
|
+
(0, chai_1.expect)(adbExec.firstCall.args[0]).to.deep.equal(['-s', 'p1', 'install', '-r', '/tmp/app.apk']);
|
|
178
|
+
(0, chai_1.expect)(adbExec.firstCall.args[1]).to.deep.equal({ timeout: AndroidDeviceManager_1.ANDROID_INSTALL_TIMEOUT_MS });
|
|
179
|
+
(0, chai_1.expect)(AndroidDeviceManager_1.ANDROID_INSTALL_TIMEOUT_MS).to.be.at.least(5 * 60000);
|
|
180
|
+
}));
|
|
169
181
|
// A phone adb reports as plugged (and every phone connected when the server
|
|
170
182
|
// starts) went through onDeviceAdded with no androidDeviceType check: a
|
|
171
183
|
// hub set to emulators only listed a real phone until its next sync pruned
|
|
@@ -73,6 +73,7 @@ const container_registration_1 = require("../helpers/container-registration");
|
|
|
73
73
|
const scratch_database_1 = require("../helpers/scratch-database");
|
|
74
74
|
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
75
75
|
const InspectorService_1 = require("../../src/services/InspectorService");
|
|
76
|
+
const OmniVisionService_1 = require("../../src/services/omni-vision/OmniVisionService");
|
|
76
77
|
const http_1 = __importDefault(require("http"));
|
|
77
78
|
const AndroidStreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidStreamService"));
|
|
78
79
|
const AndroidH264StreamService_1 = __importDefault(require("../../src/device-managers/android/AndroidH264StreamService"));
|
|
@@ -130,7 +131,11 @@ class AndroidDeviceManager {
|
|
|
130
131
|
this.at('executeShell');
|
|
131
132
|
return 'uid=2000(shell)';
|
|
132
133
|
});
|
|
133
|
-
this.
|
|
134
|
+
this.installedBytes = [];
|
|
135
|
+
this.installApp = (_udid, appPath) => __awaiter(this, void 0, void 0, function* () {
|
|
136
|
+
this.at('installApp');
|
|
137
|
+
this.installedBytes.push(fs_1.default.readFileSync(appPath, 'utf8'));
|
|
138
|
+
});
|
|
134
139
|
}
|
|
135
140
|
at(name) {
|
|
136
141
|
this.calls.push(`${typedi_1.Container.get(PluginContext_1.PluginContext).nodeId}:${name}`);
|
|
@@ -155,7 +160,7 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
155
160
|
dirs = [fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-control-keys-'))];
|
|
156
161
|
hubKeys = new JwtKeyService_1.JwtKeyService();
|
|
157
162
|
yield hubKeys.init(dirs[0]);
|
|
158
|
-
restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners, InspectorService_1.InspectorService);
|
|
163
|
+
restore = (0, container_registration_1.saveRegistrations)(JwtKeyService_1.JwtKeyService, hubSessionToken_1.HubSessionTokenIssuer, device_managers_1.XenonManager, SessionOwnerResolver_1.SessionOwnerResolver, LiveSessionOwners_1.LiveSessionOwners, InspectorService_1.InspectorService, OmniVisionService_1.OmniVisionService);
|
|
159
164
|
typedi_1.Container.set(JwtKeyService_1.JwtKeyService, hubKeys);
|
|
160
165
|
typedi_1.Container.set(hubSessionToken_1.HubSessionTokenIssuer, new hubSessionToken_1.HubSessionTokenIssuer());
|
|
161
166
|
typedi_1.Container.set(SessionOwnerResolver_1.SessionOwnerResolver, new SessionOwnerResolver_1.SessionOwnerResolver());
|
|
@@ -388,16 +393,10 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
388
393
|
(0, chai_1.expect)(res.body).to.include({ status: 'success', sessionId: 's-1' });
|
|
389
394
|
(0, chai_1.expect)(nodeSaw('appium-session')).to.have.length(0);
|
|
390
395
|
}));
|
|
391
|
-
// Not passed on
|
|
392
|
-
//
|
|
393
|
-
// Omni's AI settings are the hub's. Anything added later is refused the
|
|
394
|
-
// same way until it is on the list.
|
|
396
|
+
// Not passed on: a path is a path on one machine. Anything added later is
|
|
397
|
+
// refused the same way until it is on a list.
|
|
395
398
|
const REFUSED = [
|
|
396
399
|
['post', 'install', { appPath: '/tmp/app.apk' }],
|
|
397
|
-
['post', 'install-repository-app', { appId: 'app-1' }],
|
|
398
|
-
['post', 'upload-install'],
|
|
399
|
-
['get', 'omni-scan'],
|
|
400
|
-
['post', 'test-locator', { strategy: '-custom:ai-text', selector: 'OK' }],
|
|
401
400
|
['post', 'an-action-added-later'],
|
|
402
401
|
];
|
|
403
402
|
describe('the live preview', () => {
|
|
@@ -493,6 +492,63 @@ describe('the hub’s device control on a node’s phone', () => {
|
|
|
493
492
|
yield preview('post', 'stream/stop').expect(200);
|
|
494
493
|
}));
|
|
495
494
|
});
|
|
495
|
+
describe('apps and Omni-Vision', () => {
|
|
496
|
+
it('installs an upload on the node, streamed through the hub', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
497
|
+
const res = yield (0, loopbackRequest_1.default)(hubApp)
|
|
498
|
+
.post('/xenon/api/control/phone-1/upload-install')
|
|
499
|
+
.set('x-test-user', 'alice')
|
|
500
|
+
.attach('app', Buffer.from('uploaded-apk'), 'build-42.apk');
|
|
501
|
+
(0, chai_1.expect)(res.status, res.text).to.equal(200);
|
|
502
|
+
(0, chai_1.expect)(res.body.success).to.equal(true);
|
|
503
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:installApp']);
|
|
504
|
+
(0, chai_1.expect)(manager.installedBytes).to.deep.equal(['uploaded-apk']);
|
|
505
|
+
}));
|
|
506
|
+
it('installs an app from the hub’s library on the node, sending the file there', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
507
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-library-'));
|
|
508
|
+
dirs.push(dir);
|
|
509
|
+
const file = path_1.default.join(dir, 'stored-app');
|
|
510
|
+
fs_1.default.writeFileSync(file, 'library-apk');
|
|
511
|
+
const app = yield scratch.db.app.create({
|
|
512
|
+
data: {
|
|
513
|
+
name: 'Library App',
|
|
514
|
+
filename: 'library-app.apk',
|
|
515
|
+
filepath: file,
|
|
516
|
+
mimetype: 'application/vnd.android.package-archive',
|
|
517
|
+
size: 11,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
const res = yield asAlice('post', 'install-repository-app', { appId: app.id });
|
|
521
|
+
(0, chai_1.expect)(res.status, res.text).to.equal(200);
|
|
522
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:installApp']);
|
|
523
|
+
(0, chai_1.expect)(manager.installedBytes).to.deep.equal(['library-apk']);
|
|
524
|
+
const upload = nodeSaw('upload-install');
|
|
525
|
+
(0, chai_1.expect)(upload).to.have.length(1);
|
|
526
|
+
(0, chai_1.expect)(String(upload[0].headers['content-type'])).to.match(/^multipart\/form-data/);
|
|
527
|
+
}));
|
|
528
|
+
it('runs Omni-Vision here, on the node’s screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
529
|
+
const seen = [];
|
|
530
|
+
typedi_1.Container.set(OmniVisionService_1.OmniVisionService, {
|
|
531
|
+
analyzeScreen: (driver) => __awaiter(void 0, void 0, void 0, function* () {
|
|
532
|
+
seen.push(`scan:${(yield driver.getScreenshot()).length}`);
|
|
533
|
+
return { elements: [] };
|
|
534
|
+
}),
|
|
535
|
+
findByText: (driver, text) => __awaiter(void 0, void 0, void 0, function* () {
|
|
536
|
+
seen.push(`text:${text}:${(yield driver.getScreenshot()).length}`);
|
|
537
|
+
return [{ text }];
|
|
538
|
+
}),
|
|
539
|
+
});
|
|
540
|
+
const scan = yield asAlice('get', 'omni-scan');
|
|
541
|
+
(0, chai_1.expect)(scan.status, scan.text).to.equal(200);
|
|
542
|
+
const locator = yield asAlice('post', 'test-locator', {
|
|
543
|
+
strategy: '-custom:ai-text',
|
|
544
|
+
selector: 'Sign in',
|
|
545
|
+
});
|
|
546
|
+
(0, chai_1.expect)(locator.status, locator.text).to.equal(200);
|
|
547
|
+
(0, chai_1.expect)(locator.body.value).to.deep.equal([{ text: 'Sign in' }]);
|
|
548
|
+
(0, chai_1.expect)(seen).to.deep.equal(['scan:200', 'text:Sign in:200']);
|
|
549
|
+
(0, chai_1.expect)(manager.calls).to.deep.equal(['node-1:getScreenshot', 'node-1:getScreenshot']);
|
|
550
|
+
}));
|
|
551
|
+
});
|
|
496
552
|
it('refuses the rest, naming the node, and runs none of it on the hub', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
497
553
|
for (const [method, action, body] of REFUSED) {
|
|
498
554
|
const res = yield asAlice(method, action, body);
|
|
@@ -381,8 +381,13 @@ describe('The hub/node busy race', function () {
|
|
|
381
381
|
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
382
382
|
// The node answered a forwarded stream/start: it holds the phone now,
|
|
383
383
|
// though its last report said free.
|
|
384
|
-
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', NODE);
|
|
385
|
-
(0, chai_1.expect)(yield row('s9')).to.include({
|
|
384
|
+
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('s9', NODE, 'manual_u1_s9');
|
|
385
|
+
(0, chai_1.expect)(yield row('s9')).to.include({
|
|
386
|
+
busy: true,
|
|
387
|
+
nodeBusy: true,
|
|
388
|
+
nodeHold: 'manual_u1_s9',
|
|
389
|
+
session_id: null,
|
|
390
|
+
});
|
|
386
391
|
let session;
|
|
387
392
|
try {
|
|
388
393
|
session = yield (0, device_utils_1.allocateDeviceForSession)(caps('s9'), 200, 50, pluginArgs);
|
|
@@ -393,7 +398,7 @@ describe('The hub/node busy race', function () {
|
|
|
393
398
|
(0, chai_1.expect)(session, 'not handed to a session').to.be.an('error');
|
|
394
399
|
// The preview ended, and the node says so.
|
|
395
400
|
yield report(nodePhone('s9', { busy: false }));
|
|
396
|
-
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
|
|
401
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false, nodeHold: null });
|
|
397
402
|
}));
|
|
398
403
|
it('touches no other server’s row for the same udid', () => __awaiter(this, void 0, void 0, function* () {
|
|
399
404
|
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
@@ -401,6 +406,27 @@ describe('The hub/node busy race', function () {
|
|
|
401
406
|
(0, chai_1.expect)(yield row('s9')).to.include({ busy: false, nodeBusy: false });
|
|
402
407
|
}));
|
|
403
408
|
});
|
|
409
|
+
// The node's report is its own row, session_id included. A preview's hold
|
|
410
|
+
// there (manual_<user>_<udid>) is who holds the phone, which the hub shows
|
|
411
|
+
// (nodeHold); session_id itself stays the hub's.
|
|
412
|
+
describe('who holds a node’s phone', () => {
|
|
413
|
+
it('is the node’s preview hold, from its report, until the node lets the phone go', () => __awaiter(this, void 0, void 0, function* () {
|
|
414
|
+
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
415
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'manual_u1_s9' }));
|
|
416
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: 'manual_u1_s9', session_id: null });
|
|
417
|
+
yield report(nodePhone('s9', { busy: false, session_id: null }));
|
|
418
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: null, busy: false });
|
|
419
|
+
}));
|
|
420
|
+
it('is not a session the node runs', () => __awaiter(this, void 0, void 0, function* () {
|
|
421
|
+
yield scratch.db.device.create({ data: nodePhone('s9') });
|
|
422
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'appium-9' }));
|
|
423
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: null, nodeBusy: true, session_id: null });
|
|
424
|
+
}));
|
|
425
|
+
it('is taken from the report of a phone the hub didn’t know yet', () => __awaiter(this, void 0, void 0, function* () {
|
|
426
|
+
yield report(nodePhone('s9', { busy: true, session_id: 'manual_u1_s9' }));
|
|
427
|
+
(0, chai_1.expect)(yield row('s9')).to.include({ nodeHold: 'manual_u1_s9', busy: true });
|
|
428
|
+
}));
|
|
429
|
+
});
|
|
404
430
|
describe('a standalone server', () => {
|
|
405
431
|
it('allocates, runs and frees its own phone as before', () => __awaiter(this, void 0, void 0, function* () {
|
|
406
432
|
yield scratch.db.device.create({
|
|
@@ -38,14 +38,27 @@ describe('markNodeBusy (in-memory store)', () => {
|
|
|
38
38
|
const model = yield db_1.XenonDatabase.DeviceModel;
|
|
39
39
|
model.insert(phone(NODE));
|
|
40
40
|
model.insert(phone('http://10.0.0.8:4725'));
|
|
41
|
-
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
|
|
41
|
+
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE, 'manual_u1_s9');
|
|
42
42
|
const rows = model.find({ udid: 'mark-node-busy-s9' });
|
|
43
|
-
const byHost = Object.fromEntries(rows.map((r) => [r.host, [r.busy, r.nodeBusy]]));
|
|
43
|
+
const byHost = Object.fromEntries(rows.map((r) => { var _a; return [r.host, [r.busy, r.nodeBusy, (_a = r.nodeHold) !== null && _a !== void 0 ? _a : null]]; }));
|
|
44
44
|
(0, chai_1.expect)(byHost).to.deep.equal({
|
|
45
|
-
[NODE]: [true, true],
|
|
46
|
-
'http://10.0.0.8:4725': [false, false],
|
|
45
|
+
[NODE]: [true, true, 'manual_u1_s9'],
|
|
46
|
+
'http://10.0.0.8:4725': [false, false, null],
|
|
47
47
|
});
|
|
48
48
|
}));
|
|
49
|
+
it('takes the holder from a node’s report: a preview hold, not a session', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
|
+
const model = yield db_1.XenonDatabase.DeviceModel;
|
|
51
|
+
model.insert(phone(NODE));
|
|
52
|
+
const store = device_store_1.DeviceStoreFactory.getStore();
|
|
53
|
+
const reported = (session_id, busy) => store.addDevices([Object.assign(Object.assign({}, phone(NODE)), { busy, session_id })], { nodeReport: true });
|
|
54
|
+
const hold = () => model.findOne({ udid: 'mark-node-busy-s9', host: NODE }).nodeHold;
|
|
55
|
+
yield reported('manual_u1_mark-node-busy-s9', true);
|
|
56
|
+
(0, chai_1.expect)(hold()).to.equal('manual_u1_mark-node-busy-s9');
|
|
57
|
+
yield reported('appium-9', true);
|
|
58
|
+
(0, chai_1.expect)(hold()).to.equal(null);
|
|
59
|
+
yield reported(null, false);
|
|
60
|
+
(0, chai_1.expect)(hold()).to.equal(null);
|
|
61
|
+
}));
|
|
49
62
|
it('does nothing for a phone it doesn’t have', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
50
63
|
yield device_store_1.DeviceStoreFactory.getStore().markNodeBusy('mark-node-busy-s9', NODE);
|
|
51
64
|
(0, chai_1.expect)((yield db_1.XenonDatabase.DeviceModel).find({ udid: 'mark-node-busy-s9' })).to.deep.equal([]);
|
|
@@ -100,7 +100,7 @@ describe('nodePhoneControl', () => {
|
|
|
100
100
|
yield (0, loopbackRequest_1.default)(app()).get('/xenon/api/control/p/stream/status?ticket=spent&t=1&r=2').expect(200);
|
|
101
101
|
(0, chai_1.expect)(seen).to.deep.equal(['/xenon/api/control/p/stream/status?t=1&r=2']);
|
|
102
102
|
}));
|
|
103
|
-
it('marks the phone busy for its node once the node has started a preview
|
|
103
|
+
it('marks the phone busy for its node, held by the caller, once the node has started a preview', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
104
104
|
let answer = 200;
|
|
105
105
|
const node = (0, express_1.default)();
|
|
106
106
|
node.use((_req, res) => res.status(answer).json({}));
|
|
@@ -109,10 +109,15 @@ describe('nodePhoneControl', () => {
|
|
|
109
109
|
const marked = [];
|
|
110
110
|
const a = (0, express_1.default)();
|
|
111
111
|
a.use(express_1.default.json());
|
|
112
|
+
a.use((req, _res, next) => {
|
|
113
|
+
req.auth = { kind: 'user-session', userId: 'alice', role: 'MEMBER', scopes: 'devices' };
|
|
114
|
+
next();
|
|
115
|
+
});
|
|
112
116
|
a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
|
|
113
117
|
findDevice: (udid) => findDevice(udid),
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
119
|
+
markNodeBusy: (udid, host, hold) => __awaiter(void 0, void 0, void 0, function* () {
|
|
120
|
+
marked.push(`${udid}@${host} ${hold}`);
|
|
116
121
|
}),
|
|
117
122
|
}));
|
|
118
123
|
yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/start').send({}).expect(200);
|
|
@@ -120,7 +125,96 @@ describe('nodePhoneControl', () => {
|
|
|
120
125
|
yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/start').send({}).expect(409);
|
|
121
126
|
answer = 200;
|
|
122
127
|
yield (0, loopbackRequest_1.default)(a).get('/xenon/api/control/p/stream/status').expect(200);
|
|
123
|
-
|
|
128
|
+
// The hold the node writes for the same user (formatManualLock).
|
|
129
|
+
(0, chai_1.expect)(marked).to.deep.equal([`p@${origin} manual_alice_p`]);
|
|
130
|
+
}));
|
|
131
|
+
describe('a node’s refusal because someone holds the phone', () => {
|
|
132
|
+
// The node knows the holder's id but not their name: its database has
|
|
133
|
+
// no rows for the hub's users. The hub has, and fills it in.
|
|
134
|
+
const refusal = (holder) => ({
|
|
135
|
+
success: false,
|
|
136
|
+
error: 'device_held_by_another_user',
|
|
137
|
+
message: 'Device is being controlled by another user. Ask them to release it, or use an admin key to force-release.',
|
|
138
|
+
holder,
|
|
139
|
+
});
|
|
140
|
+
const through = (body, names) => __awaiter(void 0, void 0, void 0, function* () {
|
|
141
|
+
const node = (0, express_1.default)();
|
|
142
|
+
node.use((_req, res) => res.status(409).json(body));
|
|
143
|
+
const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
|
|
144
|
+
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
|
|
145
|
+
const a = (0, express_1.default)();
|
|
146
|
+
a.use(express_1.default.json());
|
|
147
|
+
a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
|
|
148
|
+
findDevice: (udid) => findDevice(udid),
|
|
149
|
+
describeHolder: (id) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = names[id]) !== null && _a !== void 0 ? _a : null; }),
|
|
150
|
+
}));
|
|
151
|
+
return (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/tap').send({ x: 1, y: 2 });
|
|
152
|
+
});
|
|
153
|
+
it('names the holder the node could not', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
154
|
+
const res = yield through(refusal({ userId: 'bob' }), { bob: 'bob@example.com' });
|
|
155
|
+
(0, chai_1.expect)(res.status).to.equal(409);
|
|
156
|
+
(0, chai_1.expect)(res.body.error).to.equal('device_held_by_another_user');
|
|
157
|
+
(0, chai_1.expect)(res.body.message).to.include('bob@example.com');
|
|
158
|
+
(0, chai_1.expect)(res.body.holder).to.deep.equal({ userId: 'bob', name: 'bob@example.com' });
|
|
159
|
+
}));
|
|
160
|
+
it('passes it on unchanged when this server doesn’t know the holder either', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
161
|
+
const body = refusal({ userId: 'someone-of-the-node' });
|
|
162
|
+
const res = yield through(body, {});
|
|
163
|
+
(0, chai_1.expect)(res.status).to.equal(409);
|
|
164
|
+
(0, chai_1.expect)(res.body).to.deep.equal(body);
|
|
165
|
+
}));
|
|
166
|
+
it('passes on any other 409 unchanged', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
167
|
+
const body = {
|
|
168
|
+
success: false,
|
|
169
|
+
error: 'device_recording',
|
|
170
|
+
message: 'Stop the recording first.',
|
|
171
|
+
};
|
|
172
|
+
const res = yield through(body, { bob: 'bob@example.com' });
|
|
173
|
+
(0, chai_1.expect)(res.body).to.deep.equal(body);
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
it('streams an upload to the node as it came, and waits as long as an install takes', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
177
|
+
const node = (0, express_1.default)();
|
|
178
|
+
let got;
|
|
179
|
+
node.post('/xenon/api/control/p/upload-install', (req, res) => {
|
|
180
|
+
const chunks = [];
|
|
181
|
+
req.on('data', (c) => chunks.push(c));
|
|
182
|
+
req.on('end', () => {
|
|
183
|
+
got = { type: req.headers['content-type'], body: Buffer.concat(chunks).toString() };
|
|
184
|
+
// Past the ordinary limit: an install answers when it is done.
|
|
185
|
+
setTimeout(() => res.json({ success: true }), 400);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
|
|
189
|
+
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
|
|
190
|
+
const a = (0, express_1.default)();
|
|
191
|
+
a.use(express_1.default.json());
|
|
192
|
+
a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
|
|
193
|
+
findDevice: (udid) => findDevice(udid),
|
|
194
|
+
timeoutMs: 150,
|
|
195
|
+
installTimeoutMs: 5000,
|
|
196
|
+
}));
|
|
197
|
+
const res = yield (0, loopbackRequest_1.default)(a)
|
|
198
|
+
.post('/xenon/api/control/p/upload-install')
|
|
199
|
+
.attach('app', Buffer.from('apk-bytes'), 'build-42.apk');
|
|
200
|
+
(0, chai_1.expect)(res.status, res.text).to.equal(200);
|
|
201
|
+
(0, chai_1.expect)(got === null || got === void 0 ? void 0 : got.type).to.match(/^multipart\/form-data; boundary=/);
|
|
202
|
+
(0, chai_1.expect)(got === null || got === void 0 ? void 0 : got.body).to.include('filename="build-42.apk"');
|
|
203
|
+
(0, chai_1.expect)(got === null || got === void 0 ? void 0 : got.body).to.include('apk-bytes');
|
|
204
|
+
}));
|
|
205
|
+
it('leaves the library install and Omni to this server for a node’s phone, not a cloud one', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
206
|
+
for (const [method, action] of [
|
|
207
|
+
['post', 'install-repository-app'],
|
|
208
|
+
['get', 'omni-scan'],
|
|
209
|
+
['post', 'test-locator'],
|
|
210
|
+
]) {
|
|
211
|
+
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://10.0.0.9:4725', nodeId: 'node-1' }); });
|
|
212
|
+
yield (0, loopbackRequest_1.default)(app())[method](`/xenon/api/control/p/${action}`).expect(299);
|
|
213
|
+
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://10.0.0.9:4725', cloud: '{"x":1}' }); });
|
|
214
|
+
const cloud = yield (0, loopbackRequest_1.default)(app())[method](`/xenon/api/control/p/${action}`);
|
|
215
|
+
(0, chai_1.expect)(cloud.status, action).to.equal(501);
|
|
216
|
+
(0, chai_1.expect)(cloud.body.error, action).to.equal('not_available_for_cloud_phone');
|
|
217
|
+
}
|
|
124
218
|
}));
|
|
125
219
|
it('refuses a host it must never call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
220
|
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://169.254.169.254', nodeId: 'node-1' }); });
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
require("reflect-metadata");
|
|
16
|
+
const chai_1 = require("chai");
|
|
17
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
18
|
+
const express_1 = __importDefault(require("express"));
|
|
19
|
+
const loopbackRequest_1 = __importDefault(require("../helpers/loopbackRequest"));
|
|
20
|
+
const typedi_1 = require("typedi");
|
|
21
|
+
const control_1 = __importDefault(require("../../src/app/routers/control"));
|
|
22
|
+
const device_store_1 = require("../../src/data-service/device-store");
|
|
23
|
+
const ApiKeyService_1 = require("../../src/services/ApiKeyService");
|
|
24
|
+
const device_managers_1 = require("../../src/device-managers");
|
|
25
|
+
const authMiddleware_1 = require("../../src/middleware/authMiddleware");
|
|
26
|
+
const container_registration_1 = require("../helpers/container-registration");
|
|
27
|
+
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
28
|
+
const own_node_id_1 = require("../helpers/own-node-id");
|
|
29
|
+
/**
|
|
30
|
+
* GET /control/:udid/screenshot. A capture that threw left the request
|
|
31
|
+
* unanswered: the handler had no catch, and Express 4 doesn't catch a
|
|
32
|
+
* rejected async handler. An iPhone with no live preview (no WebDriverAgent,
|
|
33
|
+
* no go-ios tunnel) did exactly that, and its Screenshot tab spun forever.
|
|
34
|
+
*/
|
|
35
|
+
const UDID = 'DEV-SHOT';
|
|
36
|
+
// The route finds the manager by constructor name.
|
|
37
|
+
class IOSDeviceManager {
|
|
38
|
+
constructor() {
|
|
39
|
+
this.getScreenshot = sinon_1.default.stub();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function buildApp() {
|
|
43
|
+
const app = (0, express_1.default)();
|
|
44
|
+
app.use(express_1.default.json());
|
|
45
|
+
const apiRouter = express_1.default.Router();
|
|
46
|
+
apiRouter.use((req, _res, next) => {
|
|
47
|
+
req.auth = {
|
|
48
|
+
kind: 'user-session',
|
|
49
|
+
userId: 'usr_me',
|
|
50
|
+
role: 'MEMBER',
|
|
51
|
+
scopes: (0, authMiddleware_1.scopesForRole)('MEMBER'),
|
|
52
|
+
rateLimit: 100,
|
|
53
|
+
};
|
|
54
|
+
next();
|
|
55
|
+
});
|
|
56
|
+
control_1.default.register(apiRouter);
|
|
57
|
+
app.use('/xenon/api', apiRouter);
|
|
58
|
+
return app;
|
|
59
|
+
}
|
|
60
|
+
describe('GET /control/:udid/screenshot', () => {
|
|
61
|
+
// The fake row is this server's own phone.
|
|
62
|
+
(0, own_node_id_1.useOwnNodeId)();
|
|
63
|
+
let manager;
|
|
64
|
+
let restoreContainer;
|
|
65
|
+
const loopback = (0, loopbackServer_1.loopbackServers)();
|
|
66
|
+
const app = () => __awaiter(void 0, void 0, void 0, function* () { return loopback.serve(buildApp()); });
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
restoreContainer = (0, container_registration_1.saveRegistrations)(ApiKeyService_1.ApiKeyService, device_managers_1.XenonManager);
|
|
69
|
+
manager = new IOSDeviceManager();
|
|
70
|
+
sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
|
|
71
|
+
findDevice: () => __awaiter(void 0, void 0, void 0, function* () {
|
|
72
|
+
return ({
|
|
73
|
+
udid: UDID,
|
|
74
|
+
host: '127.0.0.1',
|
|
75
|
+
nodeId: own_node_id_1.OWN_NODE_ID,
|
|
76
|
+
platform: 'ios',
|
|
77
|
+
busy: false,
|
|
78
|
+
session_id: null,
|
|
79
|
+
});
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
typedi_1.Container.set(ApiKeyService_1.ApiKeyService, new ApiKeyService_1.ApiKeyService());
|
|
83
|
+
typedi_1.Container.set(device_managers_1.XenonManager, { deviceInstances: () => __awaiter(void 0, void 0, void 0, function* () { return [manager]; }) });
|
|
84
|
+
});
|
|
85
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
sinon_1.default.restore();
|
|
87
|
+
restoreContainer();
|
|
88
|
+
yield loopback.closeAll();
|
|
89
|
+
}));
|
|
90
|
+
it('returns the screenshot', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
91
|
+
manager.getScreenshot.resolves('A'.repeat(200));
|
|
92
|
+
const res = yield (0, loopbackRequest_1.default)(yield app()).get(`/xenon/api/control/${UDID}/screenshot`);
|
|
93
|
+
(0, chai_1.expect)(res.status).to.equal(200);
|
|
94
|
+
(0, chai_1.expect)(res.body.screenshot).to.have.length(200);
|
|
95
|
+
}));
|
|
96
|
+
it('answers a capture that failed with 502 and the reason, rather than never', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
97
|
+
manager.getScreenshot.rejects(new Error('connect ECONNREFUSED 127.0.0.1:8100'));
|
|
98
|
+
const res = yield (0, loopbackRequest_1.default)(yield app())
|
|
99
|
+
.get(`/xenon/api/control/${UDID}/screenshot`)
|
|
100
|
+
.timeout(5000);
|
|
101
|
+
(0, chai_1.expect)(res.status).to.equal(502);
|
|
102
|
+
(0, chai_1.expect)(res.body.error).to.include('ECONNREFUSED');
|
|
103
|
+
}));
|
|
104
|
+
});
|