@xenon-device-management/xenon 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-DAYuoJDl.js} +1 -1
- package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-DEXyENUv.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-C-sJRz63.js +2 -0
- package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-B_JRYedM.js} +1 -1
- package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-CorXlSLU.js} +1 -1
- package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-BKv95DEc.js} +1 -1
- package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-Cpy5yMwg.js} +1 -1
- package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-Djp4NvyZ.js} +1 -1
- package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-CP57OKw9.js} +1 -1
- package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-BjrTit38.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-MGTzJhBf.js} +1 -1
- package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-CYBPhKPe.js} +1 -1
- package/lib/public/assets/{Table-B_Va3V05.js → Table-CdKQ9hnU.js} +1 -1
- package/lib/public/assets/{activity-DxRFWvkN.js → activity-QsRK21QF.js} +1 -1
- package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-Dn6DtRn7.js} +1 -1
- package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-DYyf17eU.js} +1 -1
- package/lib/public/assets/{apps-CgGXTlgn.js → apps-HKFKTe1J.js} +1 -1
- package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-BkA7oMRe.js} +1 -1
- package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BsSr58EU.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-B7TEV_Gr.js} +1 -1
- package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-BlmNHGcG.js} +2 -2
- package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-E4WIKtl8.js} +1 -1
- package/lib/public/assets/{button-_Y78wXiP.js → button-BIwCF2bR.js} +1 -1
- package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-CMLnXC7N.js} +1 -1
- package/lib/public/assets/{check-uGHCXe-L.js → check-zugO3p5U.js} +1 -1
- package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-sCqxpTY6.js} +1 -1
- package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-Bb59xwZc.js} +1 -1
- package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-DsvxbnTg.js} +1 -1
- package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-w69AbmbW.js} +1 -1
- package/lib/public/assets/{clock-BfYfzvSg.js → clock-C6XOs4bc.js} +1 -1
- package/lib/public/assets/{copy-DZwYE5Va.js → copy-pHk-SWBM.js} +1 -1
- package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-BDq38IcO.js} +52 -52
- package/lib/public/assets/{download-CCDYo_TN.js → download-CAHtPZmY.js} +1 -1
- package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-cmvhGOUu.js} +1 -1
- package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BrDuhOtM.js} +1 -1
- package/lib/public/assets/{index-3uxl3o7K.js → index-BQCXPOXt.js} +2 -2
- package/lib/public/assets/{index-Zy1Op5C1.js → index-C-Q4QkXb.js} +1 -1
- package/lib/public/assets/{input-CzTWvUXZ.js → input-DnUVHYax.js} +1 -1
- package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-CmOptFVa.js} +4 -4
- package/lib/public/assets/{layers-YZZGFyTX.js → layers-DRtz_bzF.js} +1 -1
- package/lib/public/assets/{lock-L8tzWgDo.js → lock-DF2VnPFm.js} +1 -1
- package/lib/public/assets/{login-DBbX-fu8.js → login-DHYffYa4.js} +1 -1
- package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-r0uPsJ6n.js} +1 -1
- package/lib/public/assets/{monitor-Di_66F2m.js → monitor-7nt9oGNx.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
- package/lib/public/assets/{network-DiSumY-1.js → network-B8m-F79k.js} +1 -1
- package/lib/public/assets/{overview-CwE4gXl5.js → overview-BHEntlXz.js} +1 -1
- package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-CuFXVUop.js} +1 -1
- package/lib/public/assets/{plus-DOS8P9CQ.js → plus-DUSlMac2.js} +1 -1
- package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CBamPy_h.js} +1 -1
- package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-DG0vZl-o.js} +1 -1
- package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-5S3T-I4g.js} +1 -1
- package/lib/public/assets/{select-Cqx7pEou.js → select-f0ci1QC7.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BXA9LUf9.js} +1 -1
- package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-TyMPgAWo.js} +1 -1
- package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-uBlXy33G.js} +1 -1
- package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-DECim5lh.js} +1 -1
- package/lib/public/assets/{settings-C-s5pypL.js → settings-IfOFbqtv.js} +1 -1
- package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-grXLf8I8.js} +1 -1
- package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-lmFk4qxW.js} +1 -1
- package/lib/public/assets/{teams-ui4IhHNC.js → teams-CwMSjVVW.js} +1 -1
- package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-BKsgmBS6.js} +1 -1
- package/lib/public/assets/{upload-DkGy_J-L.js → upload-DXH47sQS.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-DU98dxpc.js} +1 -1
- package/lib/public/assets/{users-Nb_rzRmF.js → users-V5O64rVx.js} +1 -1
- package/lib/public/assets/{users-BU0nXIVe.js → users-eXWwQZjX.js} +1 -1
- package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-BuoIHEYy.js} +1 -1
- package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-ClsxsKnC.js} +1 -1
- package/lib/public/assets/{zap-B2bH2vKX.js → zap-I10vdQXd.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 +288 -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/src/services/recording/RecordingOrchestrator.js +39 -5
- package/lib/src/services/recording/busy-precheck.js +5 -2
- package/lib/src/services/recording/nodeRecordingSource.js +156 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
- package/lib/test/unit/busy-precheck.spec.js +32 -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 +124 -4
- package/lib/test/unit/node-recording-relay.spec.js +165 -0
- package/lib/test/unit/recording-orchestrator.spec.js +86 -2
- 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
|
@@ -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,122 @@ 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
|
+
}
|
|
218
|
+
}));
|
|
219
|
+
// The node doesn't know this server is recording its phone, so it would stop
|
|
220
|
+
// the stream the recording reads; /control refuses a local phone's the same way.
|
|
221
|
+
it('refuses to stop a node phone’s preview while this server records it', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
222
|
+
const node = (0, express_1.default)();
|
|
223
|
+
const hits = [];
|
|
224
|
+
node.use((req, res) => {
|
|
225
|
+
hits.push(req.path);
|
|
226
|
+
res.json({ success: true });
|
|
227
|
+
});
|
|
228
|
+
const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
|
|
229
|
+
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
|
|
230
|
+
let recording = { groupId: 'g-1' };
|
|
231
|
+
const a = (0, express_1.default)();
|
|
232
|
+
a.use(express_1.default.json());
|
|
233
|
+
a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
|
|
234
|
+
findDevice: (udid) => findDevice(udid),
|
|
235
|
+
activeRecordingFor: () => __awaiter(void 0, void 0, void 0, function* () { return recording; }),
|
|
236
|
+
}));
|
|
237
|
+
const refused = yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/stop').send({});
|
|
238
|
+
(0, chai_1.expect)(refused.status).to.equal(409);
|
|
239
|
+
(0, chai_1.expect)(refused.body).to.include({ error: 'device_recording', groupId: 'g-1' });
|
|
240
|
+
(0, chai_1.expect)(hits).to.deep.equal([]);
|
|
241
|
+
recording = null;
|
|
242
|
+
yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/stop').send({}).expect(200);
|
|
243
|
+
(0, chai_1.expect)(hits).to.deep.equal(['/xenon/api/control/p/stream/stop']);
|
|
124
244
|
}));
|
|
125
245
|
it('refuses a host it must never call', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
246
|
findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://169.254.169.254', nodeId: 'node-1' }); });
|
|
@@ -0,0 +1,165 @@
|
|
|
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 http_1 = __importDefault(require("http"));
|
|
18
|
+
const express_1 = __importDefault(require("express"));
|
|
19
|
+
const typedi_1 = require("typedi");
|
|
20
|
+
const loopbackServer_1 = require("../helpers/loopbackServer");
|
|
21
|
+
const PluginContext_1 = require("../../src/PluginContext");
|
|
22
|
+
const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
|
|
23
|
+
const nodeRecordingSource_1 = require("../../src/services/recording/nodeRecordingSource");
|
|
24
|
+
/**
|
|
25
|
+
* Recording a node's phone on a hub. The recording's ffmpeg reads a loopback
|
|
26
|
+
* port, as for this server's own phones; for a node's phone that port is a
|
|
27
|
+
* relay to the node's MJPEG stream, signed for the recording's user on every
|
|
28
|
+
* connection. ffmpeg reconnects on any hiccup, and the hub's control token
|
|
29
|
+
* lasts a minute, so a token taken once would end the recording on the first
|
|
30
|
+
* reconnect after that.
|
|
31
|
+
*/
|
|
32
|
+
describe('nodeRecordingSource', () => {
|
|
33
|
+
const loopback = (0, loopbackServer_1.loopbackServers)();
|
|
34
|
+
const closers = [];
|
|
35
|
+
let context;
|
|
36
|
+
let saved;
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
39
|
+
saved = Object.assign({}, context);
|
|
40
|
+
context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 1, 'hub-1', '');
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
43
|
+
Object.assign(context, saved);
|
|
44
|
+
for (const close of closers.splice(0))
|
|
45
|
+
yield close();
|
|
46
|
+
yield loopback.closeAll();
|
|
47
|
+
}));
|
|
48
|
+
/** A node serving GET /control/p/stream, recording each request's token. */
|
|
49
|
+
function fakeNode() {
|
|
50
|
+
return __awaiter(this, arguments, void 0, function* (status = 200, firstFrameAfterMs = 0) {
|
|
51
|
+
const seen = [];
|
|
52
|
+
const app = (0, express_1.default)();
|
|
53
|
+
app.get('/xenon/api/control/p/stream', (req, res) => {
|
|
54
|
+
const call = { token: req.header('x-xenon-hub-token'), closed: false };
|
|
55
|
+
seen.push(call);
|
|
56
|
+
req.on('close', () => (call.closed = true));
|
|
57
|
+
if (status !== 200)
|
|
58
|
+
return res.status(status).json({ error: 'node says no' });
|
|
59
|
+
res.writeHead(200, { 'content-type': 'multipart/x-mixed-replace; boundary=f' });
|
|
60
|
+
let timer;
|
|
61
|
+
const wait = setTimeout(() => {
|
|
62
|
+
timer = setInterval(() => res.write('--f\r\nFRAME\r\n'), 30);
|
|
63
|
+
}, firstFrameAfterMs);
|
|
64
|
+
res.on('close', () => {
|
|
65
|
+
clearTimeout(wait);
|
|
66
|
+
if (timer)
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
const origin = `http://127.0.0.1:${(yield loopback.serve(app)).address().port}`;
|
|
71
|
+
return { origin, seen };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Read from the relay until `n` frames arrived, then hang up. */
|
|
75
|
+
const read = (port, n = 2) => new Promise((resolve, reject) => {
|
|
76
|
+
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/' }, (res) => {
|
|
77
|
+
let body = '';
|
|
78
|
+
res.on('data', (c) => {
|
|
79
|
+
body += c.toString();
|
|
80
|
+
if (body.split('FRAME').length > n) {
|
|
81
|
+
req.destroy();
|
|
82
|
+
resolve({ status: res.statusCode, type: String(res.headers['content-type']), body });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
86
|
+
});
|
|
87
|
+
req.on('error', (e) => (e.code === 'ECONNRESET' ? undefined : reject(e)));
|
|
88
|
+
});
|
|
89
|
+
it('relays the node’s stream, signed afresh for each connection', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
90
|
+
const node = yield fakeNode();
|
|
91
|
+
let n = 0;
|
|
92
|
+
const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: (grant) => __awaiter(void 0, void 0, void 0, function* () { return `token-${++n}-${grant.userId}-${grant.udid}`; }) });
|
|
93
|
+
closers.push(() => relay.close());
|
|
94
|
+
const first = yield read(relay.port);
|
|
95
|
+
const second = yield read(relay.port);
|
|
96
|
+
(0, chai_1.expect)(first.status).to.equal(200);
|
|
97
|
+
(0, chai_1.expect)(first.type).to.include('multipart/x-mixed-replace');
|
|
98
|
+
(0, chai_1.expect)(second.body).to.include('FRAME');
|
|
99
|
+
// The first is the relay making sure the node is streaming before it
|
|
100
|
+
// hands out the port.
|
|
101
|
+
(0, chai_1.expect)(node.seen.map((s) => s.token)).to.deep.equal([
|
|
102
|
+
'token-1-alice-p',
|
|
103
|
+
'token-2-alice-p',
|
|
104
|
+
'token-3-alice-p',
|
|
105
|
+
]);
|
|
106
|
+
}));
|
|
107
|
+
it('hangs up on the node when the reader goes, and stops listening when closed', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
108
|
+
const node = yield fakeNode();
|
|
109
|
+
const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) });
|
|
110
|
+
yield read(relay.port);
|
|
111
|
+
yield new Promise((r) => setTimeout(r, 100));
|
|
112
|
+
(0, chai_1.expect)(node.seen[1].closed, 'the node’s request ends with the reader').to.equal(true);
|
|
113
|
+
yield relay.close();
|
|
114
|
+
const refused = yield new Promise((resolve) => {
|
|
115
|
+
http_1.default
|
|
116
|
+
.get({ host: '127.0.0.1', port: relay.port, path: '/' }, () => resolve('answered'))
|
|
117
|
+
.on('error', (e) => resolve(e.code));
|
|
118
|
+
});
|
|
119
|
+
(0, chai_1.expect)(refused).to.equal('ECONNREFUSED');
|
|
120
|
+
}));
|
|
121
|
+
// Local recording starts the phone's stream before ffmpeg (ensureMjpegForRecording).
|
|
122
|
+
// Handed the port at once, ffmpeg started before the node's stream was up
|
|
123
|
+
// (an iPhone's WebDriverAgent takes seconds): the video began late, and
|
|
124
|
+
// every mark on it, timed from ffmpeg's start, was off by as much.
|
|
125
|
+
it('opens once the node is streaming, not before', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
126
|
+
const node = yield fakeNode(200, 400);
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) });
|
|
129
|
+
closers.push(() => relay.close());
|
|
130
|
+
(0, chai_1.expect)(Date.now() - t0).to.be.at.least(350);
|
|
131
|
+
}));
|
|
132
|
+
it('fails to open when the node refuses the stream, or never sends a frame', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
133
|
+
const refusing = yield fakeNode(409);
|
|
134
|
+
let error;
|
|
135
|
+
yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: refusing.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) }).catch((e) => (error = e));
|
|
136
|
+
(0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include('409');
|
|
137
|
+
const silent = yield fakeNode(200, 60000);
|
|
138
|
+
error = undefined;
|
|
139
|
+
yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: silent.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }), readyTimeoutMs: 200 }).catch((e) => (error = e));
|
|
140
|
+
(0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/first frame/);
|
|
141
|
+
}));
|
|
142
|
+
describe('recordingSourceFor', () => {
|
|
143
|
+
const rows = {};
|
|
144
|
+
const find = (udid) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = rows[udid]) !== null && _a !== void 0 ? _a : null; });
|
|
145
|
+
it('is null for this server’s own phone and an unknown one', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
146
|
+
rows.own = { udid: 'own', host: 'http://10.0.0.9:4723', nodeId: 'hub-1' };
|
|
147
|
+
(0, chai_1.expect)(yield (0, nodeRecordingSource_1.recordingSourceFor)('own', 'alice', { findDevice: find })).to.equal(null);
|
|
148
|
+
(0, chai_1.expect)(yield (0, nodeRecordingSource_1.recordingSourceFor)('ghost', 'alice', { findDevice: find })).to.equal(null);
|
|
149
|
+
}));
|
|
150
|
+
it('opens a relay for a node’s phone, and refuses a cloud provider’s', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
const node = yield fakeNode();
|
|
152
|
+
rows.p = { udid: 'p', host: node.origin, nodeId: 'node-1' };
|
|
153
|
+
const relay = yield (0, nodeRecordingSource_1.recordingSourceFor)('p', 'alice', {
|
|
154
|
+
findDevice: find,
|
|
155
|
+
controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
156
|
+
});
|
|
157
|
+
(0, chai_1.expect)(relay === null || relay === void 0 ? void 0 : relay.port).to.be.a('number');
|
|
158
|
+
closers.push(() => relay.close());
|
|
159
|
+
rows.c = { udid: 'c', host: 'https://hub.cloud.example', cloud: '{"cloudName":"x"}' };
|
|
160
|
+
let error;
|
|
161
|
+
yield (0, nodeRecordingSource_1.recordingSourceFor)('c', 'alice', { findDevice: find }).catch((e) => (error = e));
|
|
162
|
+
(0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/cloud provider/);
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -60,7 +60,7 @@ const fs = __importStar(require("fs"));
|
|
|
60
60
|
const os = __importStar(require("os"));
|
|
61
61
|
const path = __importStar(require("path"));
|
|
62
62
|
function makeOrch(overrides = {}) {
|
|
63
|
-
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
63
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
64
64
|
const busyPrecheck = (_a = overrides.busyPrecheck) !== null && _a !== void 0 ? _a : { findBusy: sinon_1.default.stub().resolves([]) };
|
|
65
65
|
const store = (_b = overrides.store) !== null && _b !== void 0 ? _b : {
|
|
66
66
|
create: sinon_1.default.stub().callsFake((i) => __awaiter(this, void 0, void 0, function* () { return (Object.assign({ id: `rec-${i.deviceUdid}` }, i)); })),
|
|
@@ -80,7 +80,9 @@ function makeOrch(overrides = {}) {
|
|
|
80
80
|
const blockDeviceFn = (_e = overrides.blockDeviceFn) !== null && _e !== void 0 ? _e : sinon_1.default.stub().resolves();
|
|
81
81
|
const unblockDeviceFn = (_f = overrides.unblockDeviceFn) !== null && _f !== void 0 ? _f : sinon_1.default.stub().resolves();
|
|
82
82
|
const ensureMjpegPortFn = (_g = overrides.ensureMjpegPortFn) !== null && _g !== void 0 ? _g : sinon_1.default.stub().callsFake(() => __awaiter(this, void 0, void 0, function* () { return 9100; }));
|
|
83
|
-
|
|
83
|
+
// This server's own phones, unless a test says otherwise.
|
|
84
|
+
const nodeSourceFn = (_h = overrides.nodeSourceFn) !== null && _h !== void 0 ? _h : sinon_1.default.stub().resolves(null);
|
|
85
|
+
const eventMgr = (_j = overrides.eventMgr) !== null && _j !== void 0 ? _j : {
|
|
84
86
|
emitRecordingStarted: sinon_1.default.stub(),
|
|
85
87
|
emitRecordingStopped: sinon_1.default.stub(),
|
|
86
88
|
emitRecordingFailed: sinon_1.default.stub(),
|
|
@@ -96,6 +98,7 @@ function makeOrch(overrides = {}) {
|
|
|
96
98
|
eventMgr: eventMgr,
|
|
97
99
|
unblockDeviceFn,
|
|
98
100
|
ensureMjpegPortFn,
|
|
101
|
+
nodeSourceFn,
|
|
99
102
|
now: overrides.now,
|
|
100
103
|
});
|
|
101
104
|
return {
|
|
@@ -108,8 +111,89 @@ function makeOrch(overrides = {}) {
|
|
|
108
111
|
unblockDeviceFn,
|
|
109
112
|
eventMgr,
|
|
110
113
|
ensureMjpegPortFn,
|
|
114
|
+
nodeSourceFn,
|
|
111
115
|
};
|
|
112
116
|
}
|
|
117
|
+
// On a hub, a node's phone is recorded from a loopback relay to its node's
|
|
118
|
+
// stream (nodeRecordingSource.ts); this server's own phones as before.
|
|
119
|
+
describe('RecordingOrchestrator: a node’s phone', () => {
|
|
120
|
+
(0, artifact_store_1.useArtifactStore)();
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
sinon_1.default.stub(deviceStoreModule.DeviceStoreFactory, 'getStore').returns({
|
|
123
|
+
findDevice: sinon_1.default
|
|
124
|
+
.stub()
|
|
125
|
+
.callsFake((_a) => __awaiter(void 0, [_a], void 0, function* ({ udid }) { return ({ udid, host: 'h', busy: false }); })),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
afterEach(() => sinon_1.default.restore());
|
|
129
|
+
/** A store that keeps what create() was given, as listGroup's rows. */
|
|
130
|
+
function rowStore() {
|
|
131
|
+
const rows = [];
|
|
132
|
+
return {
|
|
133
|
+
rows,
|
|
134
|
+
create: sinon_1.default.stub().callsFake((i) => __awaiter(this, void 0, void 0, function* () {
|
|
135
|
+
rows.push({
|
|
136
|
+
id: i.id,
|
|
137
|
+
group_id: i.groupId,
|
|
138
|
+
device_udid: i.deviceUdid,
|
|
139
|
+
device_host: i.deviceHost,
|
|
140
|
+
file_path: i.filePath,
|
|
141
|
+
started_at: new Date().toISOString(),
|
|
142
|
+
status: 'RECORDING',
|
|
143
|
+
});
|
|
144
|
+
return i;
|
|
145
|
+
})),
|
|
146
|
+
finalize: sinon_1.default.stub().resolves({}),
|
|
147
|
+
listActive: sinon_1.default.stub().resolves([]),
|
|
148
|
+
listGroup: sinon_1.default.stub().callsFake(() => __awaiter(this, void 0, void 0, function* () { return rows; })),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const relayFor = (udid, port) => {
|
|
152
|
+
const close = sinon_1.default.stub().resolves();
|
|
153
|
+
const fn = sinon_1.default.stub().callsFake((u) => __awaiter(void 0, void 0, void 0, function* () { return (u === udid ? { port, close } : null); }));
|
|
154
|
+
return { fn, close };
|
|
155
|
+
};
|
|
156
|
+
it('records it from its relay’s port, and this server’s own phone from its own stream', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
157
|
+
const relay = relayFor('N1', 5555);
|
|
158
|
+
const { orch, videoPipeline, ensureMjpegPortFn } = makeOrch({ nodeSourceFn: relay.fn });
|
|
159
|
+
yield orch.start({ udids: ['N1', 'L1'], actorId: 'alice' });
|
|
160
|
+
const ports = videoPipeline.startRecording
|
|
161
|
+
.getCalls()
|
|
162
|
+
.map((c) => [c.args[0].udid, c.args[0].mjpegPort]);
|
|
163
|
+
(0, chai_1.expect)(ports).to.deep.equal([
|
|
164
|
+
['N1', 5555],
|
|
165
|
+
['L1', 9100],
|
|
166
|
+
]);
|
|
167
|
+
(0, chai_1.expect)(ensureMjpegPortFn.getCalls().map((c) => c.args[0])).to.deep.equal(['L1']);
|
|
168
|
+
(0, chai_1.expect)(relay.fn.getCalls().map((c) => c.args)).to.deep.equal([
|
|
169
|
+
['N1', 'alice'],
|
|
170
|
+
['L1', 'alice'],
|
|
171
|
+
]);
|
|
172
|
+
const composite = videoPipeline.startComposite.firstCall.args[0].inputs.map((i) => i.mjpegPort);
|
|
173
|
+
(0, chai_1.expect)(composite).to.deep.equal([5555, 9100]);
|
|
174
|
+
}));
|
|
175
|
+
it('closes the relay once the recording has stopped', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
176
|
+
const relay = relayFor('N1', 5555);
|
|
177
|
+
const store = rowStore();
|
|
178
|
+
const { orch } = makeOrch({ nodeSourceFn: relay.fn, store });
|
|
179
|
+
const { groupId } = yield orch.start({ udids: ['N1'], actorId: 'alice' });
|
|
180
|
+
(0, chai_1.expect)(relay.close.called).to.equal(false);
|
|
181
|
+
yield orch.stop(groupId);
|
|
182
|
+
(0, chai_1.expect)(relay.close.calledOnce).to.equal(true);
|
|
183
|
+
}));
|
|
184
|
+
it('closes the relay when that phone’s recording can’t start', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
185
|
+
const relay = relayFor('N1', 5555);
|
|
186
|
+
const videoPipeline = {
|
|
187
|
+
startRecording: sinon_1.default.stub().rejects(new Error('ffmpeg would not start')),
|
|
188
|
+
stopRecording: sinon_1.default.stub().resolves('/tmp/x.mp4'),
|
|
189
|
+
startComposite: sinon_1.default.stub().resolves(),
|
|
190
|
+
stopComposite: sinon_1.default.stub().resolves(null),
|
|
191
|
+
};
|
|
192
|
+
const { orch } = makeOrch({ nodeSourceFn: relay.fn, videoPipeline });
|
|
193
|
+
yield orch.start({ udids: ['N1'], actorId: 'alice' });
|
|
194
|
+
(0, chai_1.expect)(relay.close.calledOnce).to.equal(true);
|
|
195
|
+
}));
|
|
196
|
+
});
|
|
113
197
|
describe('RecordingOrchestrator.start', () => {
|
|
114
198
|
// compositeOutputPath and per-device paths resolve through ArtifactStore.
|
|
115
199
|
(0, artifact_store_1.useArtifactStore)();
|
|
@@ -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
|
+
});
|