test-proxy-recorder 0.3.4 → 0.3.5
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/README.md +180 -524
- package/dist/index.cjs +95 -77
- package/dist/index.d.cts +8 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.mjs +95 -77
- package/dist/playwright/index.cjs +7 -8
- package/dist/playwright/index.mjs +7 -8
- package/dist/proxy.js +114 -84
- package/package.json +8 -2
package/dist/index.cjs
CHANGED
|
@@ -71,9 +71,9 @@ function processRecordings(recordings) {
|
|
|
71
71
|
const processedRecordings = [];
|
|
72
72
|
for (const [_key, keyRecordings] of recordingsByKey) {
|
|
73
73
|
keyRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
74
|
-
keyRecordings.
|
|
74
|
+
for (const [index, recording] of keyRecordings.entries()) {
|
|
75
75
|
processedRecordings.push({ ...recording, sequence: index });
|
|
76
|
-
}
|
|
76
|
+
}
|
|
77
77
|
}
|
|
78
78
|
processedRecordings.sort((a, b) => a.recordingId - b.recordingId);
|
|
79
79
|
return processedRecordings;
|
|
@@ -139,18 +139,22 @@ var ProxyServer = class {
|
|
|
139
139
|
proxy;
|
|
140
140
|
currentSession;
|
|
141
141
|
recordingsDir;
|
|
142
|
+
timeoutMs;
|
|
142
143
|
recordingIdCounter;
|
|
143
144
|
// Unique ID for each recording entry
|
|
144
145
|
sequenceCounterByKey;
|
|
145
146
|
// Sequence counter per key (endpoint)
|
|
146
147
|
replaySessions;
|
|
147
148
|
// Track multiple concurrent replay sessions by recording ID
|
|
149
|
+
sessionEvictionTimer;
|
|
150
|
+
// Periodic timer to evict idle replay sessions
|
|
148
151
|
recordingPromises;
|
|
149
152
|
// Stack of promises that resolve to completed recordings
|
|
150
153
|
flushPromise;
|
|
151
154
|
// Promise for in-progress flush operation
|
|
152
|
-
constructor(target, recordingsDir) {
|
|
155
|
+
constructor(target, recordingsDir, timeoutMs) {
|
|
153
156
|
this.target = target;
|
|
157
|
+
this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
154
158
|
this.mode = Modes.transparent;
|
|
155
159
|
this.recordingId = null;
|
|
156
160
|
this.recordingIdCounter = 0;
|
|
@@ -160,6 +164,7 @@ var ProxyServer = class {
|
|
|
160
164
|
this.currentSession = null;
|
|
161
165
|
this.recordingsDir = recordingsDir;
|
|
162
166
|
this.replaySessions = /* @__PURE__ */ new Map();
|
|
167
|
+
this.sessionEvictionTimer = null;
|
|
163
168
|
this.recordingPromises = [];
|
|
164
169
|
this.flushPromise = null;
|
|
165
170
|
this.proxy = httpProxy__default.default.createProxyServer({
|
|
@@ -222,9 +227,6 @@ var ProxyServer = class {
|
|
|
222
227
|
const corsHeaders = this.getCorsHeaders(req);
|
|
223
228
|
Object.assign(proxyRes.headers, corsHeaders);
|
|
224
229
|
}
|
|
225
|
-
getTarget() {
|
|
226
|
-
return this.target;
|
|
227
|
-
}
|
|
228
230
|
/**
|
|
229
231
|
* Extract recording ID from custom HTTP header
|
|
230
232
|
* Used for concurrent replay session routing, especially with Next.js
|
|
@@ -260,13 +262,7 @@ var ProxyServer = class {
|
|
|
260
262
|
getRecordingIdFromRequest(req) {
|
|
261
263
|
const fromHeader = this.getRecordingIdFromHeader(req);
|
|
262
264
|
const fromCookie = this.getRecordingIdFromCookie(req);
|
|
263
|
-
|
|
264
|
-
return fromHeader;
|
|
265
|
-
}
|
|
266
|
-
if (fromCookie) {
|
|
267
|
-
return fromCookie;
|
|
268
|
-
}
|
|
269
|
-
return null;
|
|
265
|
+
return fromHeader ?? fromCookie ?? null;
|
|
270
266
|
}
|
|
271
267
|
/**
|
|
272
268
|
* Get or create a replay session state for a given recording ID
|
|
@@ -286,6 +282,7 @@ var ProxyServer = class {
|
|
|
286
282
|
sortedRecordingsByKey: /* @__PURE__ */ new Map()
|
|
287
283
|
};
|
|
288
284
|
this.replaySessions.set(recordingId, session);
|
|
285
|
+
this.startSessionEvictionTimer();
|
|
289
286
|
console.log(
|
|
290
287
|
`[CONCURRENT REPLAY] Created new session for recording: ${recordingId}`
|
|
291
288
|
);
|
|
@@ -297,8 +294,9 @@ var ProxyServer = class {
|
|
|
297
294
|
* @param sessionId The session ID to clean up
|
|
298
295
|
*/
|
|
299
296
|
async cleanupSession(sessionId) {
|
|
300
|
-
|
|
301
|
-
|
|
297
|
+
this.replaySessions.delete(sessionId);
|
|
298
|
+
if (this.replaySessions.size === 0) {
|
|
299
|
+
this.stopSessionEvictionTimer();
|
|
302
300
|
}
|
|
303
301
|
if (this.recordingId === sessionId) {
|
|
304
302
|
await this.saveCurrentSession();
|
|
@@ -310,29 +308,44 @@ var ProxyServer = class {
|
|
|
310
308
|
}
|
|
311
309
|
console.log(`[CLEANUP] Session ${sessionId} cleaned up successfully`);
|
|
312
310
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const id = url.searchParams.get("id") || void 0;
|
|
317
|
-
const timeoutParam = url.searchParams.get("timeout");
|
|
318
|
-
const timeout = timeoutParam ? Number.parseInt(timeoutParam, 10) : void 0;
|
|
319
|
-
if (!mode) {
|
|
320
|
-
throw new Error("Mode parameter is required");
|
|
311
|
+
startSessionEvictionTimer() {
|
|
312
|
+
if (this.sessionEvictionTimer) {
|
|
313
|
+
return;
|
|
321
314
|
}
|
|
322
|
-
|
|
315
|
+
const CHECK_INTERVAL_MS = 3e4;
|
|
316
|
+
this.sessionEvictionTimer = setInterval(() => {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
for (const [id, session] of this.replaySessions) {
|
|
319
|
+
if (now - session.lastAccessTime >= this.timeoutMs) {
|
|
320
|
+
console.log(
|
|
321
|
+
`[EVICTION] Evicting idle replay session: ${id} (idle for ${Math.round((now - session.lastAccessTime) / 1e3)}s)`
|
|
322
|
+
);
|
|
323
|
+
this.replaySessions.delete(id);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (this.replaySessions.size === 0) {
|
|
327
|
+
this.stopSessionEvictionTimer();
|
|
328
|
+
}
|
|
329
|
+
}, CHECK_INTERVAL_MS);
|
|
330
|
+
this.sessionEvictionTimer.unref();
|
|
323
331
|
}
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if (req.method === "POST") {
|
|
329
|
-
const body = await readRequestBody(req);
|
|
330
|
-
console.log(`MODE CHANGE (${req.method})`, body);
|
|
331
|
-
return JSON.parse(body);
|
|
332
|
+
stopSessionEvictionTimer() {
|
|
333
|
+
if (this.sessionEvictionTimer) {
|
|
334
|
+
clearInterval(this.sessionEvictionTimer);
|
|
335
|
+
this.sessionEvictionTimer = null;
|
|
332
336
|
}
|
|
333
|
-
|
|
337
|
+
}
|
|
338
|
+
async parseControlBody(req) {
|
|
339
|
+
const body = await readRequestBody(req);
|
|
340
|
+
console.log(`MODE CHANGE (${req.method})`, body);
|
|
341
|
+
return JSON.parse(body);
|
|
334
342
|
}
|
|
335
343
|
async handleControlRequest(req, res) {
|
|
344
|
+
if (req.method === "HEAD") {
|
|
345
|
+
res.writeHead(HTTP_STATUS_OK);
|
|
346
|
+
res.end();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
336
349
|
if (req.method === "GET") {
|
|
337
350
|
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
338
351
|
recordingsDir: this.recordingsDir,
|
|
@@ -341,8 +354,11 @@ var ProxyServer = class {
|
|
|
341
354
|
});
|
|
342
355
|
return;
|
|
343
356
|
}
|
|
357
|
+
await this.handleControlPost(req, res);
|
|
358
|
+
}
|
|
359
|
+
async handleControlPost(req, res) {
|
|
344
360
|
try {
|
|
345
|
-
const data = await this.
|
|
361
|
+
const data = await this.parseControlBody(req);
|
|
346
362
|
const { mode, id, timeout: requestTimeout, cleanup } = data;
|
|
347
363
|
if (cleanup && id) {
|
|
348
364
|
await this.cleanupSession(id);
|
|
@@ -353,29 +369,7 @@ var ProxyServer = class {
|
|
|
353
369
|
});
|
|
354
370
|
return;
|
|
355
371
|
}
|
|
356
|
-
|
|
357
|
-
throw new Error(
|
|
358
|
-
"Mode parameter is required when cleanup is not specified"
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
const timeout = requestTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
362
|
-
this.clearModeTimeout();
|
|
363
|
-
await this.switchMode(mode, id);
|
|
364
|
-
this.setupModeTimeout(timeout);
|
|
365
|
-
if (mode === Modes.replay && id) {
|
|
366
|
-
res.setHeader(
|
|
367
|
-
"Set-Cookie",
|
|
368
|
-
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
369
|
-
);
|
|
370
|
-
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
371
|
-
}
|
|
372
|
-
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
373
|
-
success: true,
|
|
374
|
-
mode: this.mode,
|
|
375
|
-
id: this.recordingId || this.replayId,
|
|
376
|
-
timeout,
|
|
377
|
-
recordingsDir: this.recordingsDir
|
|
378
|
-
});
|
|
372
|
+
await this.applyModeChange(res, mode, id, requestTimeout);
|
|
379
373
|
} catch (error) {
|
|
380
374
|
console.error("Control request error:", error);
|
|
381
375
|
sendJsonResponse(res, HTTP_STATUS_BAD_REQUEST, {
|
|
@@ -383,6 +377,31 @@ var ProxyServer = class {
|
|
|
383
377
|
});
|
|
384
378
|
}
|
|
385
379
|
}
|
|
380
|
+
async applyModeChange(res, mode, id, requestTimeout) {
|
|
381
|
+
if (!mode) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
"Mode parameter is required when cleanup is not specified"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const timeout = requestTimeout ?? this.timeoutMs;
|
|
387
|
+
this.clearModeTimeout();
|
|
388
|
+
await this.switchMode(mode, id);
|
|
389
|
+
this.setupModeTimeout(timeout);
|
|
390
|
+
if (mode === Modes.replay && id) {
|
|
391
|
+
res.setHeader(
|
|
392
|
+
"Set-Cookie",
|
|
393
|
+
`proxy-recording-id=${encodeURIComponent(id)}; HttpOnly; Path=/; SameSite=Lax`
|
|
394
|
+
);
|
|
395
|
+
console.log(`[CONCURRENT REPLAY] Set cookie for recording: ${id}`);
|
|
396
|
+
}
|
|
397
|
+
sendJsonResponse(res, HTTP_STATUS_OK, {
|
|
398
|
+
success: true,
|
|
399
|
+
mode: this.mode,
|
|
400
|
+
id: this.recordingId || this.replayId,
|
|
401
|
+
timeout,
|
|
402
|
+
recordingsDir: this.recordingsDir
|
|
403
|
+
});
|
|
404
|
+
}
|
|
386
405
|
clearModeTimeout() {
|
|
387
406
|
clearTimeout(this.modeTimeout || 0);
|
|
388
407
|
this.modeTimeout = null;
|
|
@@ -422,7 +441,7 @@ var ProxyServer = class {
|
|
|
422
441
|
this.recordingId = null;
|
|
423
442
|
this.replayId = null;
|
|
424
443
|
this.currentSession = null;
|
|
425
|
-
|
|
444
|
+
this.clearModeTimeout();
|
|
426
445
|
console.log("Switched to transparent mode");
|
|
427
446
|
}
|
|
428
447
|
switchToRecordMode(id) {
|
|
@@ -453,7 +472,7 @@ var ProxyServer = class {
|
|
|
453
472
|
console.log(`Switched to replay mode with ID: ${id}`);
|
|
454
473
|
}
|
|
455
474
|
setupModeTimeout(timeout) {
|
|
456
|
-
|
|
475
|
+
this.clearModeTimeout();
|
|
457
476
|
this.modeTimeout = setTimeout(async () => {
|
|
458
477
|
console.log("Timeout reached, switching back to transparent mode");
|
|
459
478
|
await this.saveCurrentSession();
|
|
@@ -578,11 +597,10 @@ var ProxyServer = class {
|
|
|
578
597
|
try {
|
|
579
598
|
const sessionState = this.getOrCreateReplaySession(recordingId);
|
|
580
599
|
if (!sessionState.loadedSession) {
|
|
581
|
-
|
|
582
|
-
`Recording session file not found: ${filePath}`
|
|
600
|
+
throw Object.assign(
|
|
601
|
+
new Error(`Recording session file not found: ${filePath}`),
|
|
602
|
+
{ code: "ENOENT" }
|
|
583
603
|
);
|
|
584
|
-
error.code = "ENOENT";
|
|
585
|
-
throw error;
|
|
586
604
|
}
|
|
587
605
|
const servedForThisKey = this.getServedTracker(sessionState, key);
|
|
588
606
|
const host = req.headers.host || "unknown";
|
|
@@ -677,7 +695,7 @@ var ProxyServer = class {
|
|
|
677
695
|
res.end();
|
|
678
696
|
}
|
|
679
697
|
async handleProxyRequest(req, res) {
|
|
680
|
-
const target = this.
|
|
698
|
+
const target = this.target;
|
|
681
699
|
console.log(`[${this.mode}] ${req.method} ${req.url} -> ${target}`);
|
|
682
700
|
if (this.mode === Modes.record) {
|
|
683
701
|
await this.recordAndProxyRequest(req, res, target);
|
|
@@ -803,7 +821,7 @@ var ProxyServer = class {
|
|
|
803
821
|
this.handleReplayWebSocket(req, socket);
|
|
804
822
|
return;
|
|
805
823
|
}
|
|
806
|
-
const target = this.
|
|
824
|
+
const target = this.target;
|
|
807
825
|
console.log(`[${this.mode}] WebSocket upgrade ${req.url} -> ${target}`);
|
|
808
826
|
if (this.mode === Modes.record) {
|
|
809
827
|
this.handleRecordWebSocket(req, socket, head, target);
|
|
@@ -876,11 +894,12 @@ var ProxyServer = class {
|
|
|
876
894
|
console.error("WebSocket server error:", err);
|
|
877
895
|
});
|
|
878
896
|
}
|
|
879
|
-
handleReplayWebSocket(req, socket) {
|
|
897
|
+
async handleReplayWebSocket(req, socket) {
|
|
880
898
|
const url = req.url || "/";
|
|
881
899
|
const key = `WS_${url.replaceAll("/", "_")}`;
|
|
882
900
|
const filePath = getRecordingPath(this.recordingsDir, this.replayId);
|
|
883
|
-
|
|
901
|
+
try {
|
|
902
|
+
const session = await loadRecordingSession(filePath);
|
|
884
903
|
const wsRecording = session.websocketRecordings.find(
|
|
885
904
|
(r) => r.key === key
|
|
886
905
|
);
|
|
@@ -946,11 +965,11 @@ var ProxyServer = class {
|
|
|
946
965
|
console.log("Replay WebSocket closed");
|
|
947
966
|
});
|
|
948
967
|
});
|
|
949
|
-
}
|
|
968
|
+
} catch (error) {
|
|
950
969
|
console.error("Replay error:", error);
|
|
951
970
|
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
952
971
|
socket.destroy();
|
|
953
|
-
}
|
|
972
|
+
}
|
|
954
973
|
}
|
|
955
974
|
logServerStartup(port) {
|
|
956
975
|
console.log(`Proxy server running on http://localhost:${port}`);
|
|
@@ -961,6 +980,7 @@ var ProxyServer = class {
|
|
|
961
980
|
);
|
|
962
981
|
}
|
|
963
982
|
};
|
|
983
|
+
var registeredContexts = /* @__PURE__ */ new WeakSet();
|
|
964
984
|
function getProxyPort() {
|
|
965
985
|
const envPort = process.env.TEST_PROXY_RECORDER_PORT;
|
|
966
986
|
if (envPort) {
|
|
@@ -979,7 +999,7 @@ async function setProxyMode(mode, sessionId, timeout) {
|
|
|
979
999
|
id: sessionId,
|
|
980
1000
|
...timeout && { timeout }
|
|
981
1001
|
};
|
|
982
|
-
const response = await fetch(`http://
|
|
1002
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
983
1003
|
method: "POST",
|
|
984
1004
|
headers: { "Content-Type": "application/json" },
|
|
985
1005
|
body: JSON.stringify(body)
|
|
@@ -1002,7 +1022,7 @@ async function cleanupSession(sessionId) {
|
|
|
1002
1022
|
cleanup: true,
|
|
1003
1023
|
id: sessionId
|
|
1004
1024
|
};
|
|
1005
|
-
const response = await fetch(`http://
|
|
1025
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`, {
|
|
1006
1026
|
method: "POST",
|
|
1007
1027
|
headers: { "Content-Type": "application/json" },
|
|
1008
1028
|
body: JSON.stringify(body)
|
|
@@ -1066,7 +1086,7 @@ async function getRecordingsDir() {
|
|
|
1066
1086
|
}
|
|
1067
1087
|
const proxyPort = getProxyPort();
|
|
1068
1088
|
try {
|
|
1069
|
-
const response = await fetch(`http://
|
|
1089
|
+
const response = await fetch(`http://localhost:${proxyPort}/__control`);
|
|
1070
1090
|
if (response.ok) {
|
|
1071
1091
|
const data = await response.json();
|
|
1072
1092
|
if (data.recordingsDir) {
|
|
@@ -1153,10 +1173,8 @@ var playwrightProxy = {
|
|
|
1153
1173
|
// Ensure the handler applies to all matching requests
|
|
1154
1174
|
);
|
|
1155
1175
|
const context = page.context();
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
if (!globalThis[handlerKey]) {
|
|
1159
|
-
globalThis[handlerKey] = true;
|
|
1176
|
+
if (!registeredContexts.has(context)) {
|
|
1177
|
+
registeredContexts.add(context);
|
|
1160
1178
|
context.on("close", async () => {
|
|
1161
1179
|
try {
|
|
1162
1180
|
await cleanupSession(sessionId);
|
|
@@ -1166,7 +1184,7 @@ var playwrightProxy = {
|
|
|
1166
1184
|
error
|
|
1167
1185
|
);
|
|
1168
1186
|
} finally {
|
|
1169
|
-
delete
|
|
1187
|
+
registeredContexts.delete(context);
|
|
1170
1188
|
}
|
|
1171
1189
|
});
|
|
1172
1190
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -12,12 +12,14 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
+
private timeoutMs;
|
|
15
16
|
private recordingIdCounter;
|
|
16
17
|
private sequenceCounterByKey;
|
|
17
18
|
private replaySessions;
|
|
19
|
+
private sessionEvictionTimer;
|
|
18
20
|
private recordingPromises;
|
|
19
21
|
private flushPromise;
|
|
20
|
-
constructor(target: string, recordingsDir: string);
|
|
22
|
+
constructor(target: string, recordingsDir: string, timeoutMs?: number);
|
|
21
23
|
init(): Promise<void>;
|
|
22
24
|
listen(port: number): http.Server;
|
|
23
25
|
private setupProxyEventHandlers;
|
|
@@ -29,7 +31,6 @@ declare class ProxyServer {
|
|
|
29
31
|
*/
|
|
30
32
|
private getCorsHeaders;
|
|
31
33
|
private addCorsHeaders;
|
|
32
|
-
private getTarget;
|
|
33
34
|
/**
|
|
34
35
|
* Extract recording ID from custom HTTP header
|
|
35
36
|
* Used for concurrent replay session routing, especially with Next.js
|
|
@@ -61,9 +62,12 @@ declare class ProxyServer {
|
|
|
61
62
|
* @param sessionId The session ID to clean up
|
|
62
63
|
*/
|
|
63
64
|
private cleanupSession;
|
|
64
|
-
private
|
|
65
|
-
private
|
|
65
|
+
private startSessionEvictionTimer;
|
|
66
|
+
private stopSessionEvictionTimer;
|
|
67
|
+
private parseControlBody;
|
|
66
68
|
private handleControlRequest;
|
|
69
|
+
private handleControlPost;
|
|
70
|
+
private applyModeChange;
|
|
67
71
|
private clearModeTimeout;
|
|
68
72
|
private switchMode;
|
|
69
73
|
private switchToTransparentMode;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,12 +12,14 @@ declare class ProxyServer {
|
|
|
12
12
|
private proxy;
|
|
13
13
|
private currentSession;
|
|
14
14
|
private recordingsDir;
|
|
15
|
+
private timeoutMs;
|
|
15
16
|
private recordingIdCounter;
|
|
16
17
|
private sequenceCounterByKey;
|
|
17
18
|
private replaySessions;
|
|
19
|
+
private sessionEvictionTimer;
|
|
18
20
|
private recordingPromises;
|
|
19
21
|
private flushPromise;
|
|
20
|
-
constructor(target: string, recordingsDir: string);
|
|
22
|
+
constructor(target: string, recordingsDir: string, timeoutMs?: number);
|
|
21
23
|
init(): Promise<void>;
|
|
22
24
|
listen(port: number): http.Server;
|
|
23
25
|
private setupProxyEventHandlers;
|
|
@@ -29,7 +31,6 @@ declare class ProxyServer {
|
|
|
29
31
|
*/
|
|
30
32
|
private getCorsHeaders;
|
|
31
33
|
private addCorsHeaders;
|
|
32
|
-
private getTarget;
|
|
33
34
|
/**
|
|
34
35
|
* Extract recording ID from custom HTTP header
|
|
35
36
|
* Used for concurrent replay session routing, especially with Next.js
|
|
@@ -61,9 +62,12 @@ declare class ProxyServer {
|
|
|
61
62
|
* @param sessionId The session ID to clean up
|
|
62
63
|
*/
|
|
63
64
|
private cleanupSession;
|
|
64
|
-
private
|
|
65
|
-
private
|
|
65
|
+
private startSessionEvictionTimer;
|
|
66
|
+
private stopSessionEvictionTimer;
|
|
67
|
+
private parseControlBody;
|
|
66
68
|
private handleControlRequest;
|
|
69
|
+
private handleControlPost;
|
|
70
|
+
private applyModeChange;
|
|
67
71
|
private clearModeTimeout;
|
|
68
72
|
private switchMode;
|
|
69
73
|
private switchToTransparentMode;
|