@xenon-device-management/xenon 2.6.0 → 2.6.1

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.
Files changed (78) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-PMj0TMCJ.js → AnnotationOverlay-BR2MSqZq.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DXo-ecxe.js → ApiKeyGate-B-kYDgwU.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-DY9ltq27.js → BugReportButton-B5AX_Hq6.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-CT7nA_re.js → DeviceMosaicView-wL6hPElQ.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DX4jwZ9l.js → EmptyState-DMkfqTdS.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Czk8gPhv.js → FieldGroup-C48fEQ2R.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DTblXm4v.js → FilterMenu-BEQWiqgS.js} +1 -1
  9. package/lib/public/assets/{Menu-CdGgXICe.js → Menu-ChzfRWa3.js} +1 -1
  10. package/lib/public/assets/{Modal-CtzTciQY.js → Modal-D4LAJuLk.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BjJH7AyA.js → RecordingPage-CErhVVhI.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BkgX2xkA.js → RecordingsPage-CiwOgvEu.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DEXgl8Qw.js → SegmentedControl-CEGbL4Og.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BnwAoJhk.js → SettingCard-Ixm55gZS.js} +1 -1
  15. package/lib/public/assets/{Table-hmNY-ICS.js → Table-C7osPLTJ.js} +1 -1
  16. package/lib/public/assets/{activity-Bmuu2fcZ.js → activity-B6i2UUaG.js} +1 -1
  17. package/lib/public/assets/{ai-settings-uAzHwRK_.js → ai-settings-D7gK7P2L.js} +1 -1
  18. package/lib/public/assets/{api-keys-CFZeXLs7.js → api-keys-DrBdSosA.js} +1 -1
  19. package/lib/public/assets/{apps-DgwQRGg6.js → apps-BWEUuoIK.js} +1 -1
  20. package/lib/public/assets/{arrow-left-D5x8OLs-.js → arrow-left-DcJsqErm.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DRYGEjl5.js → arrow-right-_g7OIFHi.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DLtHJsd6.js → arrow-up-right-Bn6qC1nH.js} +1 -1
  23. package/lib/public/assets/{auth-shell-D_TLCi0Y.js → auth-shell-DslyrtGF.js} +2 -2
  24. package/lib/public/assets/{builds-page-CwKR7w5o.js → builds-page-CEZ9IZ9S.js} +1 -1
  25. package/lib/public/assets/{button-CbVMa9PL.js → button-C8WXV74W.js} +1 -1
  26. package/lib/public/assets/{calendar-DgmwU-Oq.js → calendar-D6zwX5yt.js} +1 -1
  27. package/lib/public/assets/{check-Du5vvLoJ.js → check-DHCAmQzm.js} +1 -1
  28. package/lib/public/assets/{chevron-left-CiRlzB7w.js → chevron-left-DAjBpmZL.js} +1 -1
  29. package/lib/public/assets/{chevron-right-i93nTPHX.js → chevron-right-CXeI6N1r.js} +1 -1
  30. package/lib/public/assets/{circle-check-DxYO7yp5.js → circle-check-CXNkbyeD.js} +1 -1
  31. package/lib/public/assets/{circle-x-Dw1DFg0I.js → circle-x-5wGBLvP4.js} +1 -1
  32. package/lib/public/assets/{clock-0q-GqRU0.js → clock-B6D_VfTn.js} +1 -1
  33. package/lib/public/assets/{copy-Ds-F075f.js → copy-B01YbVjr.js} +1 -1
  34. package/lib/public/assets/{device-explorer-Dsldlx2g.js → device-explorer-BJYJi3-I.js} +1 -1
  35. package/lib/public/assets/{download-BdJEWL8b.js → download-CYOnymUH.js} +1 -1
  36. package/lib/public/assets/{forgot-password-D7eNuw-V.js → forgot-password-ChGJEfSH.js} +1 -1
  37. package/lib/public/assets/{funnel-CY3Ovebz.js → funnel-Bsf-_RST.js} +1 -1
  38. package/lib/public/assets/{index-BpJwb7cQ.js → index-DC1NFStQ.js} +1 -1
  39. package/lib/public/assets/{index-tW_VASh2.js → index-rullcuA-.js} +2 -2
  40. package/lib/public/assets/{input-DvCYrL_q.js → input-DAFtgyoC.js} +1 -1
  41. package/lib/public/assets/{keycodes-CyOBCkU6.js → keycodes-0UtDeccx.js} +1 -1
  42. package/lib/public/assets/{layers-ChEcsAc2.js → layers-C9QEszDA.js} +1 -1
  43. package/lib/public/assets/{list-checks-EWcywAEC.js → list-checks-BDs_WJqC.js} +1 -1
  44. package/lib/public/assets/{lock-DmkRlmQB.js → lock-Wfb-mfRt.js} +1 -1
  45. package/lib/public/assets/{login-BXeYWAn-.js → login-7ukAAXMQ.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-Cd37WZ5e.js → maintenance-settings-HJVTzD1e.js} +1 -1
  47. package/lib/public/assets/{monitor-BTqiDmvk.js → monitor-CdNmIwjG.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-BrwX4M5d.js → mouse-pointer-2-qVSJPuaG.js} +1 -1
  49. package/lib/public/assets/{network-8uySyPSM.js → network-BdrZxJP7.js} +1 -1
  50. package/lib/public/assets/{overview-CdBGYC95.js → overview-C0JVQ10q.js} +1 -1
  51. package/lib/public/assets/{page-header-BYzd5kfp.js → page-header-BNgF7qLO.js} +1 -1
  52. package/lib/public/assets/{plus-BIekJsya.js → plus-bjDKJLSM.js} +1 -1
  53. package/lib/public/assets/{profile-page-a-nQ7xFX.js → profile-page-Dgqh9iFO.js} +1 -1
  54. package/lib/public/assets/{reset-password-7Gbj29w1.js → reset-password-BKkG0r19.js} +1 -1
  55. package/lib/public/assets/{runbook-page-Bgd9u7W6.js → runbook-page-CMhZZJVm.js} +1 -1
  56. package/lib/public/assets/{select-DEpRzm_3.js → select-C563Otbb.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-CvqjXfvp.js → selector-detail-page-DhnD3eaZ.js} +1 -1
  58. package/lib/public/assets/{selector-health-BEzkwE_K.js → selector-health-B7Oe-x9R.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-Dl_IE1Je.js → selector-health-page-BzP4CuGG.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-CuF_EwRE.js → session-detail-page-DYccmo6I.js} +1 -1
  61. package/lib/public/assets/{settings-CxLw5tlF.js → settings-BtiYwsCN.js} +1 -1
  62. package/lib/public/assets/{stat-tile-DFuoxV5Y.js → stat-tile-BlmNnQFY.js} +1 -1
  63. package/lib/public/assets/{tablet-DJBOerD6.js → tablet-Dt9_zCsE.js} +1 -1
  64. package/lib/public/assets/{teams-CDQQdVRl.js → teams-C-TH1VHJ.js} +1 -1
  65. package/lib/public/assets/{trash-2-B_zQvjI3.js → trash-2-Cy3hmCGn.js} +1 -1
  66. package/lib/public/assets/{upload-9FV8q3NB.js → upload-BzeSfIf5.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-Cx5GHMnk.js → use-password-reset-mode-CFPUu_Pu.js} +1 -1
  68. package/lib/public/assets/{users-DC0IoOuZ.js → users-ByGYDd3w.js} +1 -1
  69. package/lib/public/assets/{users-ep-SOGaV.js → users-CZwclIT8.js} +1 -1
  70. package/lib/public/assets/{video-off-BmUg-gKW.js → video-off-soegR0jK.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-CwUBzLGC.js → webhook-settings-DHkmj77L.js} +1 -1
  72. package/lib/public/assets/{zap-CBld_5xf.js → zap-4ACghMsl.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/services/recording/RecordingOrchestrator.js +35 -50
  75. package/lib/test/integration/cross-workflow.spec.js +30 -6
  76. package/lib/test/unit/recording-orchestrator.spec.js +71 -9
  77. package/lib/test/unit/recording-source-ended.spec.js +17 -4
  78. package/package.json +1 -1
@@ -135,9 +135,22 @@ class RecordingError extends Error {
135
135
  }
136
136
  }
137
137
  exports.RecordingError = RecordingError;
138
+ /**
139
+ * The live preview's leave path, as a page that stops watching uses it: after
140
+ * a short grace, the phone's preview is stopped and its hold released unless
141
+ * someone watches it or another recording reads it. Imported when called:
142
+ * control.ts is a router, loaded long after this service.
143
+ */
144
+ function leaveDevice(udid) {
145
+ void Promise.resolve().then(() => __importStar(require('../../app/routers/control'))).then(({ previewLeaves }) => previewLeaves.leave(udid))
146
+ .catch((err) => {
147
+ var _a;
148
+ recLog.warn(`Could not hand ${udid} to the preview's leave path: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
149
+ });
150
+ }
138
151
  let RecordingOrchestrator = class RecordingOrchestrator {
139
152
  constructor(deps = {}) {
140
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
153
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
141
154
  /**
142
155
  * Recording ids currently being closed out. Both the user-initiated stop and
143
156
  * an ffmpeg self-exit finalize rows, and they can land at the same instant —
@@ -158,7 +171,8 @@ let RecordingOrchestrator = class RecordingOrchestrator {
158
171
  this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
159
172
  this.nodeSourceFn = (_j = deps.nodeSourceFn) !== null && _j !== void 0 ? _j : ((udid, actorId) => (0, nodeRecordingSource_1.recordingSourceFor)(udid, actorId));
160
173
  this.probeDurationMsFn = (_k = deps.probeDurationMsFn) !== null && _k !== void 0 ? _k : probeDuration_1.probeVideoDurationMs;
161
- this.now = (_l = deps.now) !== null && _l !== void 0 ? _l : Date.now;
174
+ this.leaveDeviceFn = (_l = deps.leaveDeviceFn) !== null && _l !== void 0 ? _l : leaveDevice;
175
+ this.now = (_m = deps.now) !== null && _m !== void 0 ? _m : Date.now;
162
176
  }
163
177
  get busyPrecheck() {
164
178
  return this._deps.busyPrecheck;
@@ -398,6 +412,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
398
412
  failReason = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : 'ffmpeg_stop_failed';
399
413
  }
400
414
  // Its ffmpeg is done reading; a group's composite stops before this.
415
+ const fromNode = this.nodeSources.has(r.id);
401
416
  yield this.closeNodeSource(r.id);
402
417
  // How long the video actually is, not how long the session was open. Those
403
418
  // agree only while capture keeps up: a recording whose device died at 26s
@@ -444,7 +459,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
444
459
  recLog.warn(`finalize failed for ${r.id}: ${err === null || err === void 0 ? void 0 : err.message}`);
445
460
  }
446
461
  this.gate.release(r.id);
447
- yield this.releaseLockIfNotInheritedFromMosaic(r.device_udid, (_c = r.device_host) !== null && _c !== void 0 ? _c : '127.0.0.1');
462
+ yield this.letGoOfDevice(r.device_udid, (_c = r.device_host) !== null && _c !== void 0 ? _c : '127.0.0.1', fromNode);
448
463
  if (durationMs !== undefined) {
449
464
  durationHistogram.record(durationMs, {
450
465
  outcome: status === 'STOPPED' ? attributes_1.OUTCOME.SUCCESS : attributes_1.OUTCOME.FAILURE,
@@ -647,57 +662,27 @@ let RecordingOrchestrator = class RecordingOrchestrator {
647
662
  }))();
648
663
  }
649
664
  /**
650
- * Stop-time unblock that respects the mosaic preview. If the mosaic's
651
- * stream service is still actively streaming this device (its lock value
652
- * is `manual_${udid}` — same string the orchestrator wrote, but conceptually
653
- * owned by the mosaic), we leave the lock in place so the preview keeps
654
- * working and so automation doesn't grab the device underneath it.
665
+ * Let go of a phone whose recording has ended.
666
+ *
667
+ * This server's own phone goes to the live preview's leave path, as a page
668
+ * that stops watching it does. After a short grace, which also lets this
669
+ * recording's own ffmpeg connection drop, its preview is stopped and its
670
+ * hold released, unless someone watches it or another recording reads it.
671
+ * Whether its stream is running says nothing: the recording starts the
672
+ * stream itself when none runs (ensureMjpegForRecording). Taking that
673
+ * stream for a preview's kept the phone held, with nobody watching, until
674
+ * the stream's idle watchdog ten minutes later.
675
+ *
676
+ * A node's phone has no stream on this server, so nobody here watches it:
677
+ * its hold goes at once.
655
678
  */
656
- releaseLockIfNotInheritedFromMosaic(udid, host) {
679
+ letGoOfDevice(udid, host, fromNode) {
657
680
  return __awaiter(this, void 0, void 0, function* () {
658
- let mosaicStreamRunning = false;
659
- try {
660
- // Lazy import — keeps the orchestrator usable in tests that don't
661
- // wire stream services into the DI container.
662
- const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('../../device-managers/ios/IOSStreamService')));
663
- const ios = typedi_1.Container.get(IOSStreamService);
664
- const iosSession = ios.getStreamStatus(udid);
665
- if ((iosSession === null || iosSession === void 0 ? void 0 : iosSession.status) === 'running')
666
- mosaicStreamRunning = true;
667
- }
668
- catch (_a) {
669
- /* ignore — service may not be registered in this context */
670
- }
671
- if (!mosaicStreamRunning) {
672
- try {
673
- const { default: AndroidStreamService } = yield Promise.resolve().then(() => __importStar(require('../../device-managers/android/AndroidStreamService')));
674
- const android = typedi_1.Container.get(AndroidStreamService);
675
- const aSession = android.getStreamStatus(udid);
676
- if ((aSession === null || aSession === void 0 ? void 0 : aSession.status) === 'running')
677
- mosaicStreamRunning = true;
678
- }
679
- catch (_b) {
680
- /* ignore */
681
- }
682
- }
683
- // H.264 preview (androidH264) has no MJPEG session — check it too or we
684
- // drop the mosaic lock underneath a live WebCodecs tile.
685
- if (!mosaicStreamRunning) {
686
- try {
687
- const { default: AndroidH264StreamService } = yield Promise.resolve().then(() => __importStar(require('../../device-managers/android/AndroidH264StreamService')));
688
- if (typedi_1.Container.get(AndroidH264StreamService).getMultiplexer(udid)) {
689
- mosaicStreamRunning = true;
690
- }
691
- }
692
- catch (_c) {
693
- /* ignore */
694
- }
695
- }
696
- if (mosaicStreamRunning) {
697
- recLog.info(`Recording stop: keeping manual lock on ${udid} because the mosaic preview is still active.`);
681
+ if (fromNode) {
682
+ yield this.tryUnblock(udid, host);
698
683
  return;
699
684
  }
700
- yield this.tryUnblock(udid, host);
685
+ this.leaveDeviceFn(udid);
701
686
  });
702
687
  }
703
688
  addBookmark(groupId, recordingId, timecodeMs, label, note) {
@@ -51,6 +51,7 @@ const sinon_1 = __importDefault(require("sinon"));
51
51
  const RecordingOrchestrator_1 = require("../../src/services/recording/RecordingOrchestrator");
52
52
  const concurrency_gate_1 = require("../../src/services/recording/concurrency-gate");
53
53
  const deviceStoreModule = __importStar(require("../../src/data-service/device-store"));
54
+ const artifact_store_1 = require("../helpers/artifact-store");
54
55
  /**
55
56
  * The six scenarios from the spec's cross-workflow risk matrix
56
57
  * (docs/superpowers/specs/2026-04-27-multi-device-mosaic-and-proof-pack-design.md
@@ -58,7 +59,7 @@ const deviceStoreModule = __importStar(require("../../src/data-service/device-st
58
59
  * workflow is broken by the new free-form recording surface.
59
60
  */
60
61
  function makeOrch(overrides = {}) {
61
- var _a, _b, _c, _d, _e, _f, _g;
62
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
62
63
  const busyPrecheck = (_a = overrides.busyPrecheck) !== null && _a !== void 0 ? _a : { findBusy: sinon_1.default.stub().resolves([]) };
63
64
  const store = (_b = overrides.store) !== null && _b !== void 0 ? _b : {
64
65
  create: sinon_1.default.stub().callsFake((i) => __awaiter(this, void 0, void 0, function* () { return (Object.assign({ id: `rec-${i.deviceUdid}` }, i)); })),
@@ -73,7 +74,14 @@ function makeOrch(overrides = {}) {
73
74
  };
74
75
  const blockDeviceFn = (_e = overrides.blockDeviceFn) !== null && _e !== void 0 ? _e : sinon_1.default.stub().resolves();
75
76
  const unblockDeviceFn = (_f = overrides.unblockDeviceFn) !== null && _f !== void 0 ? _f : sinon_1.default.stub().resolves();
76
- const eventMgr = (_g = overrides.eventMgr) !== null && _g !== void 0 ? _g : {
77
+ // Each phone is this server's own, read from a live MJPEG port: the real
78
+ // ones would look the phone up and start its stream.
79
+ const nodeSourceFn = (_g = overrides.nodeSourceFn) !== null && _g !== void 0 ? _g : sinon_1.default.stub().resolves(null);
80
+ const ensureMjpegPortFn = (_h = overrides.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : sinon_1.default.stub().resolves(9100);
81
+ // Where a stopped recording hands its phone: the preview's leave path,
82
+ // which releases the hold once nobody watches it.
83
+ const leaveDeviceFn = (_j = overrides.leaveDeviceFn) !== null && _j !== void 0 ? _j : sinon_1.default.stub();
84
+ const eventMgr = (_k = overrides.eventMgr) !== null && _k !== void 0 ? _k : {
77
85
  emitRecordingStarted: sinon_1.default.stub(),
78
86
  emitRecordingStopped: sinon_1.default.stub(),
79
87
  emitRecordingFailed: sinon_1.default.stub(),
@@ -88,10 +96,26 @@ function makeOrch(overrides = {}) {
88
96
  blockDeviceFn,
89
97
  eventMgr: eventMgr,
90
98
  unblockDeviceFn,
99
+ nodeSourceFn,
100
+ ensureMjpegPortFn,
101
+ leaveDeviceFn,
91
102
  });
92
- return { orch, busyPrecheck, store, gate, videoPipeline, blockDeviceFn, unblockDeviceFn, eventMgr };
103
+ return {
104
+ orch,
105
+ busyPrecheck,
106
+ store,
107
+ gate,
108
+ videoPipeline,
109
+ blockDeviceFn,
110
+ unblockDeviceFn,
111
+ leaveDeviceFn,
112
+ eventMgr,
113
+ };
93
114
  }
94
115
  describe('Cross-workflow integration: manual + automation safety (6 scenarios)', () => {
116
+ // start() resolves each recording's file through the ArtifactStore, as the
117
+ // server registers it at boot.
118
+ (0, artifact_store_1.useArtifactStore)();
95
119
  let factoryStub;
96
120
  beforeEach(() => {
97
121
  factoryStub = sinon_1.default
@@ -150,8 +174,8 @@ describe('Cross-workflow integration: manual + automation safety (6 scenarios)',
150
174
  const passedSessionIds = calls.map((c) => c.args[0].sessionId);
151
175
  (0, chai_1.expect)(new Set(passedSessionIds).size).to.equal(2);
152
176
  }));
153
- it('4. ffmpeg stop throws (simulated device unplug) — Recording marked FAILED, manual block released', () => __awaiter(void 0, void 0, void 0, function* () {
154
- const { orch, store, unblockDeviceFn, videoPipeline } = makeOrch({
177
+ it('4. ffmpeg stop throws (simulated device unplug) — Recording marked FAILED, phone handed on to release its block', () => __awaiter(void 0, void 0, void 0, function* () {
178
+ const { orch, store, leaveDeviceFn } = makeOrch({
155
179
  store: {
156
180
  listGroup: sinon_1.default.stub().resolves([
157
181
  {
@@ -172,7 +196,7 @@ describe('Cross-workflow integration: manual + automation safety (6 scenarios)',
172
196
  yield orch.stop('grp-X');
173
197
  const finalizeArgs = store.finalize.firstCall.args[1];
174
198
  (0, chai_1.expect)(finalizeArgs.status).to.equal('FAILED');
175
- (0, chai_1.expect)(unblockDeviceFn.calledWith('U1', '127.0.0.1')).to.equal(true);
199
+ (0, chai_1.expect)(leaveDeviceFn.calledOnceWith('U1')).to.equal(true);
176
200
  }));
177
201
  it('5. server restart with orphan RECORDING row — recoverOnBoot marks FAILED + releases block', () => __awaiter(void 0, void 0, void 0, function* () {
178
202
  const { orch, store, unblockDeviceFn } = makeOrch({
@@ -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, _j;
63
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
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)); })),
@@ -82,7 +82,9 @@ function makeOrch(overrides = {}) {
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
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 : {
85
+ // Where a stopped recording hands its phone: the preview's leave path.
86
+ const leaveDeviceFn = (_j = overrides.leaveDeviceFn) !== null && _j !== void 0 ? _j : sinon_1.default.stub();
87
+ const eventMgr = (_k = overrides.eventMgr) !== null && _k !== void 0 ? _k : {
86
88
  emitRecordingStarted: sinon_1.default.stub(),
87
89
  emitRecordingStopped: sinon_1.default.stub(),
88
90
  emitRecordingFailed: sinon_1.default.stub(),
@@ -99,6 +101,7 @@ function makeOrch(overrides = {}) {
99
101
  unblockDeviceFn,
100
102
  ensureMjpegPortFn,
101
103
  nodeSourceFn,
104
+ leaveDeviceFn,
102
105
  now: overrides.now,
103
106
  });
104
107
  return {
@@ -112,6 +115,7 @@ function makeOrch(overrides = {}) {
112
115
  eventMgr,
113
116
  ensureMjpegPortFn,
114
117
  nodeSourceFn,
118
+ leaveDeviceFn,
115
119
  };
116
120
  }
117
121
  // On a hub, a node's phone is recorded from a loopback relay to its node's
@@ -181,6 +185,17 @@ describe('RecordingOrchestrator: a node’s phone', () => {
181
185
  yield orch.stop(groupId);
182
186
  (0, chai_1.expect)(relay.close.calledOnce).to.equal(true);
183
187
  }));
188
+ // This server runs no stream of a node's phone, so nobody here can be
189
+ // watching it: its hold goes as soon as the recording is finalized.
190
+ it('releases a node phone’s hold at once, not through the leave path', () => __awaiter(void 0, void 0, void 0, function* () {
191
+ const relay = relayFor('N1', 5555);
192
+ const store = rowStore();
193
+ const { orch, unblockDeviceFn, leaveDeviceFn } = makeOrch({ nodeSourceFn: relay.fn, store });
194
+ const { groupId } = yield orch.start({ udids: ['N1'], actorId: 'alice' });
195
+ yield orch.stop(groupId);
196
+ (0, chai_1.expect)(unblockDeviceFn.calledWith('N1', 'h')).to.equal(true);
197
+ (0, chai_1.expect)(leaveDeviceFn.called).to.equal(false);
198
+ }));
184
199
  it('closes the relay when that phone’s recording can’t start', () => __awaiter(void 0, void 0, void 0, function* () {
185
200
  const relay = relayFor('N1', 5555);
186
201
  const videoPipeline = {
@@ -371,7 +386,7 @@ describe('RecordingOrchestrator.start', () => {
371
386
  describe('RecordingOrchestrator.stop', () => {
372
387
  // compositeOutputPath and per-device paths resolve through ArtifactStore.
373
388
  (0, artifact_store_1.useArtifactStore)();
374
- it('finalizes, releases gate, releases blocks, emits stopped', () => __awaiter(void 0, void 0, void 0, function* () {
389
+ it('finalizes, releases gate, hands each phone to the leave path, emits stopped', () => __awaiter(void 0, void 0, void 0, function* () {
375
390
  const store = {
376
391
  listGroup: sinon_1.default.stub().resolves([
377
392
  {
@@ -399,15 +414,63 @@ describe('RecordingOrchestrator.stop', () => {
399
414
  };
400
415
  const unblockDeviceFn = sinon_1.default.stub().resolves();
401
416
  const eventMgr = { emitRecordingStopped: sinon_1.default.stub() };
402
- const { orch } = makeOrch({ store, gate, videoPipeline, unblockDeviceFn, eventMgr });
417
+ const { orch, leaveDeviceFn } = makeOrch({
418
+ store,
419
+ gate,
420
+ videoPipeline,
421
+ unblockDeviceFn,
422
+ eventMgr,
423
+ });
403
424
  yield orch.stop('grp-1');
404
425
  (0, chai_1.expect)(videoPipeline.stopRecording.callCount).to.equal(2);
405
426
  (0, chai_1.expect)(store.finalize.callCount).to.equal(2);
406
427
  (0, chai_1.expect)(gate.release.callCount).to.equal(2);
407
- (0, chai_1.expect)(unblockDeviceFn.callCount).to.equal(2);
428
+ // The leave path releases each hold once nobody watches the phone; the
429
+ // recording doesn't release it itself.
430
+ (0, chai_1.expect)(leaveDeviceFn.getCalls().map((c) => c.args[0])).to.deep.equal(['U1', 'U2']);
431
+ (0, chai_1.expect)(unblockDeviceFn.called).to.equal(false);
408
432
  (0, chai_1.expect)(eventMgr.emitRecordingStopped.callCount).to.equal(1);
409
433
  }));
410
- it('marks FAILED when ffmpeg stop throws but still releases the block', () => __awaiter(void 0, void 0, void 0, function* () {
434
+ // A recording starts its phone's stream when none runs
435
+ // (ensureMjpegForRecording). Taking that running stream for a preview kept
436
+ // the phone held until the stream's idle watchdog, ten minutes later.
437
+ it('hands the phone to the leave path even while its stream runs', () => __awaiter(void 0, void 0, void 0, function* () {
438
+ const { default: AndroidStreamService } = yield Promise.resolve().then(() => __importStar(require('../../src/device-managers/android/AndroidStreamService')));
439
+ const had = typedi_1.Container.has(AndroidStreamService);
440
+ const previous = had ? typedi_1.Container.get(AndroidStreamService) : undefined;
441
+ typedi_1.Container.set(AndroidStreamService, {
442
+ getStreamStatus: () => ({ status: 'running', viewerCount: 0 }),
443
+ });
444
+ try {
445
+ const store = {
446
+ listGroup: sinon_1.default.stub().resolves([
447
+ {
448
+ id: 'r1',
449
+ device_udid: 'U1',
450
+ device_host: '127.0.0.1',
451
+ file_path: '/nonexistent/r1.mp4',
452
+ started_at: new Date(Date.now() - 5000),
453
+ },
454
+ ]),
455
+ finalize: sinon_1.default.stub().resolves({}),
456
+ };
457
+ const videoPipeline = {
458
+ startRecording: sinon_1.default.stub(),
459
+ stopRecording: sinon_1.default.stub().resolves('/tmp/x.mp4'),
460
+ stopComposite: sinon_1.default.stub().resolves(null),
461
+ };
462
+ const { orch, leaveDeviceFn } = makeOrch({ store, videoPipeline });
463
+ yield orch.stop('grp-1');
464
+ (0, chai_1.expect)(leaveDeviceFn.calledOnceWith('U1')).to.equal(true);
465
+ }
466
+ finally {
467
+ if (had)
468
+ typedi_1.Container.set(AndroidStreamService, previous);
469
+ else
470
+ typedi_1.Container.remove(AndroidStreamService);
471
+ }
472
+ }));
473
+ it('marks FAILED when ffmpeg stop throws but still hands the phone on', () => __awaiter(void 0, void 0, void 0, function* () {
411
474
  const store = {
412
475
  listGroup: sinon_1.default.stub().resolves([
413
476
  {
@@ -425,11 +488,10 @@ describe('RecordingOrchestrator.stop', () => {
425
488
  stopRecording: sinon_1.default.stub().rejects(new Error('ffmpeg died')),
426
489
  stopComposite: sinon_1.default.stub().resolves(null),
427
490
  };
428
- const unblockDeviceFn = sinon_1.default.stub().resolves();
429
- const { orch } = makeOrch({ store, videoPipeline, unblockDeviceFn });
491
+ const { orch, leaveDeviceFn } = makeOrch({ store, videoPipeline });
430
492
  yield orch.stop('grp-1');
431
493
  (0, chai_1.expect)(store.finalize.firstCall.args[1].status).to.equal('FAILED');
432
- (0, chai_1.expect)(unblockDeviceFn.calledWith('U1', '127.0.0.1')).to.equal(true);
494
+ (0, chai_1.expect)(leaveDeviceFn.calledOnceWith('U1')).to.equal(true);
433
495
  }));
434
496
  });
435
497
  describe('RecordingOrchestrator.recoverOnBoot', () => {
@@ -54,6 +54,8 @@ function makeOrch(row, overrides = {}) {
54
54
  emitRecordingAnnotation: sinon_1.default.stub(),
55
55
  };
56
56
  const unblockDeviceFn = sinon_1.default.stub().resolves();
57
+ // Where an ended recording hands its phone: the preview's leave path.
58
+ const leaveDeviceFn = sinon_1.default.stub();
57
59
  // Never spawn ffmpeg from a unit test; individual cases override this.
58
60
  const probeDurationMsFn = (_c = overrides.probeDurationMsFn) !== null && _c !== void 0 ? _c : sinon_1.default.stub().resolves(undefined);
59
61
  const orch = new RecordingOrchestrator_1.RecordingOrchestrator({
@@ -66,8 +68,18 @@ function makeOrch(row, overrides = {}) {
66
68
  blockDeviceFn: sinon_1.default.stub().resolves(),
67
69
  unblockDeviceFn,
68
70
  ensureMjpegPortFn: sinon_1.default.stub().resolves(9100),
71
+ leaveDeviceFn,
69
72
  });
70
- return { orch, store, gate, videoPipeline, eventMgr, unblockDeviceFn, probeDurationMsFn };
73
+ return {
74
+ orch,
75
+ store,
76
+ gate,
77
+ videoPipeline,
78
+ eventMgr,
79
+ unblockDeviceFn,
80
+ leaveDeviceFn,
81
+ probeDurationMsFn,
82
+ };
71
83
  }
72
84
  describe('RecordingOrchestrator: ffmpeg exiting on its own', () => {
73
85
  // The in-memory stores, also when this file runs on its own: without
@@ -106,13 +118,14 @@ describe('RecordingOrchestrator: ffmpeg exiting on its own', () => {
106
118
  (0, chai_1.expect)(store.finalize.firstCall.args[1].status).to.equal('FAILED');
107
119
  (0, chai_1.expect)(store.finalize.firstCall.args[1].failReason).to.equal('empty_or_corrupt_mp4');
108
120
  }));
109
- it('releases the gate slot and the device lock', () => __awaiter(void 0, void 0, void 0, function* () {
121
+ it('releases the gate slot and hands the phone on to release its hold', () => __awaiter(void 0, void 0, void 0, function* () {
110
122
  const row = makeRow();
111
- const { orch, gate, unblockDeviceFn } = makeOrch(row);
123
+ const { orch, gate, leaveDeviceFn } = makeOrch(row);
112
124
  gate.tryAcquire(['rec-1']);
113
125
  yield orch.handleSourceEnded('rec-1', 0);
114
126
  (0, chai_1.expect)(gate.tryAcquire(['rec-1']), 'slot must be free again').to.be.true;
115
- (0, chai_1.expect)(unblockDeviceFn.called, 'manual lock released').to.be.true;
127
+ (0, chai_1.expect)(leaveDeviceFn.calledOnceWith(row.device_udid), 'phone handed to the leave path').to.be
128
+ .true;
116
129
  }));
117
130
  it('does nothing when the row was already finalized', () => __awaiter(void 0, void 0, void 0, function* () {
118
131
  // A manual Stop that won the race already closed it out.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {