@livedesk/hub 0.1.59 → 0.1.64
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/package.json +1 -1
- package/src/agents/agent-tool-registry.js +1 -1
- package/src/agents/codex-agent-runtime.js +5 -4
- package/src/auth/workspace-access.js +2 -0
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/live-capture-transition-retry.mjs +25 -10
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/plan-device-access.mjs +100 -0
- package/src/plan-device-access.test.mjs +97 -0
- package/src/pwa-runtime-diagnostic-store.mjs +365 -0
- package/src/remote-hub.js +1144 -463
- package/src/server.js +867 -248
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/transport/relay-hub-control.js +10 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
|
@@ -125,7 +125,7 @@ export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
|
125
125
|
return releaseOwner(owner, reason);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function cancelClient(client, reason = 'client-closed') {
|
|
128
|
+
function cancelClient(client, reason = 'client-closed') {
|
|
129
129
|
const state = clientStates.get(client);
|
|
130
130
|
if (!state) {
|
|
131
131
|
return 0;
|
|
@@ -138,8 +138,22 @@ export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
|
138
138
|
}
|
|
139
139
|
state.intentGenerations.clear();
|
|
140
140
|
clientStates.delete(client);
|
|
141
|
-
return cancelled;
|
|
142
|
-
}
|
|
141
|
+
return cancelled;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cancelDevice(client, deviceId, reason = 'device-cancelled') {
|
|
145
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
146
|
+
const state = clientStates.get(client);
|
|
147
|
+
if (!state || !normalizedDeviceId) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const cancelled = releaseOwner(state.owners.get(normalizedDeviceId), reason);
|
|
151
|
+
state.intentGenerations.delete(normalizedDeviceId);
|
|
152
|
+
if (state.owners.size === 0 && state.intentGenerations.size === 0) {
|
|
153
|
+
clientStates.delete(client);
|
|
154
|
+
}
|
|
155
|
+
return cancelled;
|
|
156
|
+
}
|
|
143
157
|
|
|
144
158
|
function schedule(client, request = {}) {
|
|
145
159
|
const deviceId = String(request.deviceId || '').trim();
|
|
@@ -288,10 +302,11 @@ export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
|
288
302
|
}
|
|
289
303
|
|
|
290
304
|
return Object.freeze({
|
|
291
|
-
beginIntent,
|
|
292
|
-
schedule,
|
|
293
|
-
complete,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
305
|
+
beginIntent,
|
|
306
|
+
schedule,
|
|
307
|
+
complete,
|
|
308
|
+
cancelDevice,
|
|
309
|
+
cancelClient,
|
|
310
|
+
snapshot
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -10,7 +10,7 @@ export function parseExactLiveStreamMonitorIndex(value) {
|
|
|
10
10
|
: null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
|
|
13
|
+
export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
|
|
14
14
|
const expectedMonitorIndex = parseExactLiveStreamMonitorIndex(expectedBinding?.monitorIndex);
|
|
15
15
|
const activeMonitorIndex = parseExactLiveStreamMonitorIndex(activeStream?.monitorIndex);
|
|
16
16
|
const latestFrame = activeStream?.latestFrame;
|
|
@@ -34,5 +34,197 @@ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBindi
|
|
|
34
34
|
&& Number(latestFrame?.captureGeneration || 0) === expectedCaptureGeneration
|
|
35
35
|
&& expectedMonitorIndex !== null
|
|
36
36
|
&& activeMonitorIndex === expectedMonitorIndex
|
|
37
|
-
&& frameMonitorIndex === expectedMonitorIndex;
|
|
38
|
-
}
|
|
37
|
+
&& frameMonitorIndex === expectedMonitorIndex;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS = 3_000;
|
|
41
|
+
export const READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS = 250;
|
|
42
|
+
|
|
43
|
+
function normalizedPurpose(value) {
|
|
44
|
+
return String(value || '').trim().toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveInteger(value) {
|
|
48
|
+
const parsed = Number(value);
|
|
49
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Own exactly one short stop-reconcile timer per device. A replacement Control
|
|
54
|
+
* transition cancels that timer before a PWA Wall can claim the released
|
|
55
|
+
* native capture; its Ready event then rebinds observers immediately.
|
|
56
|
+
*/
|
|
57
|
+
export function createReadOnlyControlPresentationReconcileCoordinator({
|
|
58
|
+
onReconcile,
|
|
59
|
+
graceMs = READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS,
|
|
60
|
+
setTimeoutFn = setTimeout,
|
|
61
|
+
clearTimeoutFn = clearTimeout
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (typeof onReconcile !== 'function') {
|
|
64
|
+
throw new TypeError('onReconcile must be a function');
|
|
65
|
+
}
|
|
66
|
+
if (typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') {
|
|
67
|
+
throw new TypeError('timer functions must be callable');
|
|
68
|
+
}
|
|
69
|
+
const normalizedGraceMs = Number(graceMs);
|
|
70
|
+
if (!Number.isFinite(normalizedGraceMs)
|
|
71
|
+
|| normalizedGraceMs < 1
|
|
72
|
+
|| normalizedGraceMs > 5_000) {
|
|
73
|
+
throw new RangeError('graceMs must be between 1 and 5000 milliseconds');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pendingByDeviceId = new Map();
|
|
77
|
+
let closed = false;
|
|
78
|
+
|
|
79
|
+
function normalizeDeviceId(deviceId) {
|
|
80
|
+
return String(deviceId || '').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cancelForControlTransition(deviceId) {
|
|
84
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
85
|
+
const owner = normalizedDeviceId ? pendingByDeviceId.get(normalizedDeviceId) : null;
|
|
86
|
+
if (!owner) return false;
|
|
87
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
88
|
+
clearTimeoutFn(owner.timer);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scheduleAfterConfirmedStop(deviceId) {
|
|
93
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
94
|
+
if (closed || !normalizedDeviceId || pendingByDeviceId.has(normalizedDeviceId)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const owner = { timer: null };
|
|
98
|
+
pendingByDeviceId.set(normalizedDeviceId, owner);
|
|
99
|
+
try {
|
|
100
|
+
owner.timer = setTimeoutFn(() => {
|
|
101
|
+
if (closed || pendingByDeviceId.get(normalizedDeviceId) !== owner) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
105
|
+
onReconcile(normalizedDeviceId);
|
|
106
|
+
}, normalizedGraceMs);
|
|
107
|
+
owner.timer?.unref?.();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (pendingByDeviceId.get(normalizedDeviceId) === owner) {
|
|
110
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function reconcileReadyControl(deviceId) {
|
|
118
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
119
|
+
if (closed || !normalizedDeviceId) return false;
|
|
120
|
+
cancelForControlTransition(normalizedDeviceId);
|
|
121
|
+
onReconcile(normalizedDeviceId);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function close() {
|
|
126
|
+
if (closed) return;
|
|
127
|
+
closed = true;
|
|
128
|
+
for (const owner of pendingByDeviceId.values()) {
|
|
129
|
+
clearTimeoutFn(owner.timer);
|
|
130
|
+
}
|
|
131
|
+
pendingByDeviceId.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
scheduleAfterConfirmedStop,
|
|
136
|
+
cancelForControlTransition,
|
|
137
|
+
reconcileReadyControl,
|
|
138
|
+
close,
|
|
139
|
+
getPendingCount: () => pendingByDeviceId.size
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Bind a Wall presentation observer to an already healthy Control encoder.
|
|
145
|
+
* This returns metadata only: it never starts, stops, or replaces native work.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveReadOnlyControlPresentationBorrow({
|
|
148
|
+
device,
|
|
149
|
+
liveOptions,
|
|
150
|
+
monitorIndex,
|
|
151
|
+
nowEpochMs = Date.now()
|
|
152
|
+
} = {}) {
|
|
153
|
+
if (liveOptions?.allowReadOnlyControlBorrow !== true
|
|
154
|
+
|| normalizedPurpose(liveOptions?.streamPurpose) !== 'wall') {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const active = device?.activeLiveStream;
|
|
159
|
+
const latest = active?.latestFrame;
|
|
160
|
+
const sessionId = String(device?.sessionId || '').trim();
|
|
161
|
+
const streamId = String(active?.streamId || '').trim();
|
|
162
|
+
const commandId = String(active?.commandId || '').trim();
|
|
163
|
+
const captureGeneration = positiveInteger(active?.captureGeneration);
|
|
164
|
+
const activeMonitorIndex = parseExactLiveStreamMonitorIndex(active?.monitorIndex);
|
|
165
|
+
const requestedMonitorIndex = parseExactLiveStreamMonitorIndex(monitorIndex);
|
|
166
|
+
const frameMode = String(active?.frameMode || active?.mode || '').trim().toLowerCase();
|
|
167
|
+
const requestedFrameMode = String(liveOptions?.frameMode || liveOptions?.mode || '').trim().toLowerCase();
|
|
168
|
+
const lastFrameAtEpochMs = Date.parse(String(active?.lastFrameAt || ''));
|
|
169
|
+
const currentEpochMs = Number(nowEpochMs);
|
|
170
|
+
const frameIdleMs = Number.isFinite(lastFrameAtEpochMs) && Number.isFinite(currentEpochMs)
|
|
171
|
+
? Math.max(0, currentEpochMs - lastFrameAtEpochMs)
|
|
172
|
+
: Number.POSITIVE_INFINITY;
|
|
173
|
+
const latestMonitorIndex = parseExactLiveStreamMonitorIndex(latest?.monitorIndex);
|
|
174
|
+
|
|
175
|
+
if (active?.active !== true
|
|
176
|
+
|| active?.open !== true
|
|
177
|
+
|| active?.stopPending === true
|
|
178
|
+
|| !!String(active?.stopCommandId || '').trim()
|
|
179
|
+
|| !!String(active?.pendingDescriptor?.commandId || '').trim()
|
|
180
|
+
|| normalizedPurpose(active?.streamPurpose) !== 'control'
|
|
181
|
+
|| frameMode !== 'mode3-h264-hw'
|
|
182
|
+
|| requestedFrameMode !== 'mode3-h264-hw'
|
|
183
|
+
|| !sessionId
|
|
184
|
+
|| !streamId
|
|
185
|
+
|| !commandId
|
|
186
|
+
|| captureGeneration <= 0
|
|
187
|
+
|| requestedMonitorIndex === null
|
|
188
|
+
|| activeMonitorIndex !== requestedMonitorIndex
|
|
189
|
+
|| active?.readyFrameReceived !== true
|
|
190
|
+
|| Number(active?.framesReceived || 0) <= 0
|
|
191
|
+
|| frameIdleMs > READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS
|
|
192
|
+
|| latest?.currentGenerationVerified !== true
|
|
193
|
+
|| String(latest?.sessionId || '') !== sessionId
|
|
194
|
+
|| String(latest?.streamId || '') !== streamId
|
|
195
|
+
|| String(latest?.commandId || '') !== commandId
|
|
196
|
+
|| Number(latest?.captureGeneration || 0) !== captureGeneration
|
|
197
|
+
|| latestMonitorIndex !== activeMonitorIndex
|
|
198
|
+
|| normalizedPurpose(latest?.streamPurpose) !== 'control') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
commandId,
|
|
205
|
+
sessionId,
|
|
206
|
+
streamId,
|
|
207
|
+
streamPurpose: 'control',
|
|
208
|
+
fps: positiveInteger(active?.fps) || 1,
|
|
209
|
+
mode: String(active?.mode || active?.frameMode || 'mode3-h264-hw'),
|
|
210
|
+
frameMode: 'mode3-h264-hw',
|
|
211
|
+
monitorIndex: activeMonitorIndex,
|
|
212
|
+
captureGeneration,
|
|
213
|
+
ready: true,
|
|
214
|
+
reused: true,
|
|
215
|
+
readOnlyControlBorrow: true,
|
|
216
|
+
presentationPurpose: 'wall',
|
|
217
|
+
requestedProfile: {
|
|
218
|
+
fps: Number(liveOptions?.fps || 0),
|
|
219
|
+
maxWidth: Number(liveOptions?.maxWidth || 0),
|
|
220
|
+
maxHeight: Number(liveOptions?.maxHeight || 0),
|
|
221
|
+
quality: Number(liveOptions?.quality || 0)
|
|
222
|
+
},
|
|
223
|
+
effectiveProfile: {
|
|
224
|
+
fps: Number(active?.fps || 0),
|
|
225
|
+
maxWidth: Number(active?.maxWidth || 0),
|
|
226
|
+
maxHeight: Number(active?.maxHeight || 0),
|
|
227
|
+
quality: Number(active?.quality || 0)
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
function normalizeDeviceId(value) {
|
|
2
|
+
return String(value || '').trim();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizedPlanDeviceLimit(value) {
|
|
6
|
+
if (!Number.isFinite(Number(value))) return Number.POSITIVE_INFINITY;
|
|
7
|
+
return Math.max(0, Math.floor(Number(value)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stableSlot(value) {
|
|
11
|
+
const number = Number(value);
|
|
12
|
+
return Number.isFinite(number) ? number : Number.MAX_SAFE_INTEGER;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Selects the exact connected Client set owned by a finite plan.
|
|
17
|
+
*
|
|
18
|
+
* The result is independent from browser selection, monitor changes and input
|
|
19
|
+
* cadence. Slot order is the product-visible owner; device identity closes an
|
|
20
|
+
* equal-slot tie. Callers retain the returned Set outside transport hot paths.
|
|
21
|
+
*/
|
|
22
|
+
export function stablePlanAllowedDeviceIds(devices, deviceLimit) {
|
|
23
|
+
const limit = normalizedPlanDeviceLimit(deviceLimit);
|
|
24
|
+
if (limit <= 0) return [];
|
|
25
|
+
const stableDevices = (Array.isArray(devices) ? devices : [])
|
|
26
|
+
.filter(device => device && normalizeDeviceId(device.deviceId))
|
|
27
|
+
.slice()
|
|
28
|
+
.sort((left, right) => {
|
|
29
|
+
const leftSlot = stableSlot(left.slotNumber);
|
|
30
|
+
const rightSlot = stableSlot(right.slotNumber);
|
|
31
|
+
if (leftSlot !== rightSlot) return leftSlot - rightSlot;
|
|
32
|
+
return normalizeDeviceId(left.deviceId).localeCompare(normalizeDeviceId(right.deviceId));
|
|
33
|
+
});
|
|
34
|
+
const ids = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const device of stableDevices) {
|
|
37
|
+
const deviceId = normalizeDeviceId(device.deviceId);
|
|
38
|
+
if (seen.has(deviceId)) continue;
|
|
39
|
+
seen.add(deviceId);
|
|
40
|
+
ids.push(deviceId);
|
|
41
|
+
if (ids.length >= limit) break;
|
|
42
|
+
}
|
|
43
|
+
return ids;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPlanDeviceAccessSnapshot(devices, deviceLimit, generation = 0, {
|
|
47
|
+
exemptDeviceIds = []
|
|
48
|
+
} = {}) {
|
|
49
|
+
const exemptDeviceIdSet = new Set((Array.isArray(exemptDeviceIds) ? exemptDeviceIds : [exemptDeviceIds])
|
|
50
|
+
.map(normalizeDeviceId)
|
|
51
|
+
.filter(Boolean));
|
|
52
|
+
const allConnectedDevices = (Array.isArray(devices) ? devices : [])
|
|
53
|
+
.filter(device => device?.connected === true && normalizeDeviceId(device.deviceId));
|
|
54
|
+
const connectedDevices = allConnectedDevices
|
|
55
|
+
.filter(device => !exemptDeviceIdSet.has(normalizeDeviceId(device.deviceId)));
|
|
56
|
+
const connectedDeviceIds = [...new Set(connectedDevices.map(device => normalizeDeviceId(device.deviceId)))];
|
|
57
|
+
const exemptConnectedDeviceIds = [...new Set(allConnectedDevices
|
|
58
|
+
.map(device => normalizeDeviceId(device.deviceId))
|
|
59
|
+
.filter(deviceId => exemptDeviceIdSet.has(deviceId)))];
|
|
60
|
+
const allowedDeviceIds = [...new Set([
|
|
61
|
+
...exemptDeviceIdSet,
|
|
62
|
+
...stablePlanAllowedDeviceIds(connectedDevices, deviceLimit)
|
|
63
|
+
])];
|
|
64
|
+
const allowedDeviceIdSet = new Set(allowedDeviceIds);
|
|
65
|
+
const blockedDeviceIds = connectedDeviceIds.filter(deviceId => !allowedDeviceIdSet.has(deviceId));
|
|
66
|
+
return {
|
|
67
|
+
generation: Math.max(0, Number(generation) || 0),
|
|
68
|
+
deviceLimit: normalizedPlanDeviceLimit(deviceLimit),
|
|
69
|
+
connectedDeviceIds,
|
|
70
|
+
exemptConnectedDeviceIds,
|
|
71
|
+
allowedDeviceIds,
|
|
72
|
+
allowedDeviceIdSet,
|
|
73
|
+
blockedDeviceIds
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function partitionPlanDeviceIds(deviceIds, snapshot) {
|
|
78
|
+
const normalizedIds = [...new Set((Array.isArray(deviceIds) ? deviceIds : [deviceIds])
|
|
79
|
+
.map(normalizeDeviceId)
|
|
80
|
+
.filter(Boolean))];
|
|
81
|
+
if (!Number.isFinite(snapshot?.deviceLimit)) {
|
|
82
|
+
return { allowedDeviceIds: normalizedIds, blockedDeviceIds: [] };
|
|
83
|
+
}
|
|
84
|
+
const allowedSet = snapshot?.allowedDeviceIdSet instanceof Set
|
|
85
|
+
? snapshot.allowedDeviceIdSet
|
|
86
|
+
: new Set(snapshot?.allowedDeviceIds || []);
|
|
87
|
+
return {
|
|
88
|
+
allowedDeviceIds: normalizedIds.filter(deviceId => allowedSet.has(deviceId)),
|
|
89
|
+
blockedDeviceIds: normalizedIds.filter(deviceId => !allowedSet.has(deviceId))
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function planDeviceAccessSnapshotChanged(previous, next) {
|
|
94
|
+
if (!previous || !next) return true;
|
|
95
|
+
if (previous.deviceLimit !== next.deviceLimit) return true;
|
|
96
|
+
if (previous.allowedDeviceIds.length !== next.allowedDeviceIds.length
|
|
97
|
+
|| previous.blockedDeviceIds.length !== next.blockedDeviceIds.length) return true;
|
|
98
|
+
return previous.allowedDeviceIds.some((deviceId, index) => deviceId !== next.allowedDeviceIds[index])
|
|
99
|
+
|| previous.blockedDeviceIds.some((deviceId, index) => deviceId !== next.blockedDeviceIds[index]);
|
|
100
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
createPlanDeviceAccessSnapshot,
|
|
5
|
+
partitionPlanDeviceIds,
|
|
6
|
+
planDeviceAccessSnapshotChanged,
|
|
7
|
+
stablePlanAllowedDeviceIds as stableHubPlanAllowedDeviceIds
|
|
8
|
+
} from './plan-device-access.mjs';
|
|
9
|
+
import { stablePlanAllowedDeviceIds as stableBrowserPlanAllowedDeviceIds } from '../../../apps/web/src/wall-stream-contract.mjs';
|
|
10
|
+
|
|
11
|
+
const clients = [6, 3, 1, 5, 2, 4].map(slotNumber => ({
|
|
12
|
+
deviceId: `client-${slotNumber}`,
|
|
13
|
+
slotNumber,
|
|
14
|
+
connected: true
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
test('Hub and browser choose the same stable Free, Plus, Pro, and Team device owners', () => {
|
|
18
|
+
for (const limit of [5, 15, 50, Number.POSITIVE_INFINITY]) {
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
stableHubPlanAllowedDeviceIds(clients, limit),
|
|
21
|
+
stableBrowserPlanAllowedDeviceIds(clients, limit)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
stableHubPlanAllowedDeviceIds(clients, 5),
|
|
26
|
+
['client-1', 'client-2', 'client-3', 'client-4', 'client-5']
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('Plus stops at 15 Clients, Pro stops at 50, and Team remains unbounded', () => {
|
|
31
|
+
const manyClients = Array.from({ length: 51 }, (_, index) => ({
|
|
32
|
+
deviceId: `client-${index + 1}`,
|
|
33
|
+
slotNumber: index + 1,
|
|
34
|
+
connected: true
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 15).allowedDeviceIds.length, 15);
|
|
38
|
+
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 15).blockedDeviceIds.length, 36);
|
|
39
|
+
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 50).allowedDeviceIds.length, 50);
|
|
40
|
+
assert.deepEqual(createPlanDeviceAccessSnapshot(manyClients, 50).blockedDeviceIds, ['client-51']);
|
|
41
|
+
assert.equal(
|
|
42
|
+
createPlanDeviceAccessSnapshot(manyClients, Number.POSITIVE_INFINITY).blockedDeviceIds.length,
|
|
43
|
+
0
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('finite plans reject the exact excess Client instead of counting request width', () => {
|
|
48
|
+
const snapshot = createPlanDeviceAccessSnapshot(clients, 5, 7);
|
|
49
|
+
assert.deepEqual(snapshot.allowedDeviceIds, ['client-1', 'client-2', 'client-3', 'client-4', 'client-5']);
|
|
50
|
+
assert.deepEqual(snapshot.blockedDeviceIds, ['client-6']);
|
|
51
|
+
assert.deepEqual(partitionPlanDeviceIds(['client-1', 'client-6'], snapshot), {
|
|
52
|
+
allowedDeviceIds: ['client-1'],
|
|
53
|
+
blockedDeviceIds: ['client-6']
|
|
54
|
+
});
|
|
55
|
+
assert.deepEqual(partitionPlanDeviceIds('client-6', snapshot), {
|
|
56
|
+
allowedDeviceIds: [],
|
|
57
|
+
blockedDeviceIds: ['client-6']
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a Team to Free downgrade changes one bounded snapshot and preserves stable owners', () => {
|
|
62
|
+
const unlimited = createPlanDeviceAccessSnapshot(clients, Number.POSITIVE_INFINITY, 1);
|
|
63
|
+
const downgraded = createPlanDeviceAccessSnapshot(clients, 5, 2);
|
|
64
|
+
assert.equal(planDeviceAccessSnapshotChanged(unlimited, downgraded), true);
|
|
65
|
+
assert.deepEqual(downgraded.allowedDeviceIds, ['client-1', 'client-2', 'client-3', 'client-4', 'client-5']);
|
|
66
|
+
assert.deepEqual(downgraded.blockedDeviceIds, ['client-6']);
|
|
67
|
+
assert.equal(planDeviceAccessSnapshotChanged(downgraded, {
|
|
68
|
+
...downgraded,
|
|
69
|
+
generation: 3,
|
|
70
|
+
allowedDeviceIdSet: new Set(downgraded.allowedDeviceIds)
|
|
71
|
+
}), false, 'generation alone must not churn healthy browser lanes');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('disconnected Clients do not occupy a connected-device plan slot', () => {
|
|
75
|
+
const snapshot = createPlanDeviceAccessSnapshot([
|
|
76
|
+
...clients,
|
|
77
|
+
{ deviceId: 'client-0', slotNumber: 0, connected: false }
|
|
78
|
+
], 5);
|
|
79
|
+
assert.equal(snapshot.allowedDeviceIdSet.has('client-0'), false);
|
|
80
|
+
assert.equal(snapshot.allowedDeviceIds.length, 5);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the exact Hub manager stays controllable without consuming a Client slot', () => {
|
|
84
|
+
const snapshot = createPlanDeviceAccessSnapshot([
|
|
85
|
+
...clients,
|
|
86
|
+
{ deviceId: 'hub-manager', slotNumber: 0, connected: true }
|
|
87
|
+
], 5, 8, { exemptDeviceIds: ['hub-manager'] });
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(snapshot.connectedDeviceIds, clients.map(client => client.deviceId));
|
|
90
|
+
assert.deepEqual(snapshot.exemptConnectedDeviceIds, ['hub-manager']);
|
|
91
|
+
assert.equal(snapshot.allowedDeviceIdSet.has('hub-manager'), true);
|
|
92
|
+
assert.deepEqual(snapshot.blockedDeviceIds, ['client-6']);
|
|
93
|
+
assert.deepEqual(partitionPlanDeviceIds(['hub-manager', 'client-6'], snapshot), {
|
|
94
|
+
allowedDeviceIds: ['hub-manager'],
|
|
95
|
+
blockedDeviceIds: ['client-6']
|
|
96
|
+
});
|
|
97
|
+
});
|