dashcam 0.8.4 → 1.0.1-beta.10
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/.dashcam/cli-config.json +3 -0
- package/.dashcam/recording.log +135 -0
- package/.dashcam/web-config.json +11 -0
- package/.github/RELEASE.md +59 -0
- package/.github/workflows/publish.yml +43 -0
- package/BACKWARD_COMPATIBILITY.md +177 -0
- package/LOG_TRACKING_GUIDE.md +225 -0
- package/README.md +709 -155
- package/bin/dashcam-background.js +177 -0
- package/bin/dashcam.cjs +8 -0
- package/bin/dashcam.js +696 -0
- package/bin/index.js +63 -0
- package/examples/execute-script.js +152 -0
- package/examples/simple-test.js +37 -0
- package/lib/applicationTracker.js +311 -0
- package/lib/auth.js +222 -0
- package/lib/binaries.js +21 -0
- package/lib/config.js +34 -0
- package/lib/extension-logs/helpers.js +182 -0
- package/lib/extension-logs/index.js +347 -0
- package/lib/extension-logs/manager.js +344 -0
- package/lib/ffmpeg.js +155 -0
- package/lib/logTracker.js +23 -0
- package/lib/logger.js +118 -0
- package/lib/logs/index.js +488 -0
- package/lib/permissions.js +85 -0
- package/lib/processManager.js +317 -0
- package/lib/recorder.js +690 -0
- package/lib/store.js +58 -0
- package/lib/tracking/FileTracker.js +105 -0
- package/lib/tracking/FileTrackerManager.js +62 -0
- package/lib/tracking/LogsTracker.js +161 -0
- package/lib/tracking/active-win.js +212 -0
- package/lib/tracking/icons/darwin.js +39 -0
- package/lib/tracking/icons/index.js +167 -0
- package/lib/tracking/icons/windows.js +27 -0
- package/lib/tracking/idle.js +82 -0
- package/lib/tracking.js +23 -0
- package/lib/uploader.js +456 -0
- package/lib/utilities/jsonl.js +77 -0
- package/lib/webLogsDaemon.js +234 -0
- package/lib/websocket/server.js +223 -0
- package/package.json +53 -21
- package/recording.log +814 -0
- package/sea-bundle.mjs +34595 -0
- package/test-page.html +15 -0
- package/test.log +1 -0
- package/test_run.log +48 -0
- package/test_workflow.sh +154 -0
- package/examples/crash-test.js +0 -11
- package/examples/github-issue.sh +0 -1
- package/examples/protocol.html +0 -22
- package/index.js +0 -158
- package/lib.js +0 -199
- package/recorder.js +0 -85
package/lib/uploader.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { Upload } from '@aws-sdk/lib-storage';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { logger, logFunctionCall } from './logger.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { auth } from './auth.js';
|
|
7
|
+
import got from 'got';
|
|
8
|
+
|
|
9
|
+
class Uploader {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.uploadCallbacks = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
createS3Client(sts) {
|
|
15
|
+
const logExit = logFunctionCall('createS3Client', { region: sts.region });
|
|
16
|
+
|
|
17
|
+
logger.verbose('Creating S3 client', {
|
|
18
|
+
region: sts.region,
|
|
19
|
+
fallbackRegion: 'us-east-2',
|
|
20
|
+
bucket: sts.bucket,
|
|
21
|
+
hasAccessKey: !!sts.accessKeyId,
|
|
22
|
+
hasSecretKey: !!sts.secretAccessKey,
|
|
23
|
+
hasSessionToken: !!sts.sessionToken
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const clientRegion = sts.region || 'us-east-2';
|
|
27
|
+
|
|
28
|
+
const client = new S3Client({
|
|
29
|
+
credentials: {
|
|
30
|
+
accessKeyId: sts.accessKeyId,
|
|
31
|
+
secretAccessKey: sts.secretAccessKey,
|
|
32
|
+
sessionToken: sts.sessionToken
|
|
33
|
+
},
|
|
34
|
+
region: clientRegion,
|
|
35
|
+
maxAttempts: 3
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
logger.debug('S3 client created', {
|
|
39
|
+
configuredRegion: clientRegion,
|
|
40
|
+
bucket: sts.bucket
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
logExit();
|
|
44
|
+
return client;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
generateUploadParams(sts, fileType, extension) {
|
|
48
|
+
// Use the key from STS directly - it already includes proper extension
|
|
49
|
+
const key = sts.file;
|
|
50
|
+
|
|
51
|
+
logger.debug('Generating upload params:', {
|
|
52
|
+
bucket: sts.bucket,
|
|
53
|
+
key,
|
|
54
|
+
contentType: `${fileType}/${extension}`
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
Bucket: sts.bucket,
|
|
59
|
+
Key: key,
|
|
60
|
+
ContentType: `${fileType}/${extension}`,
|
|
61
|
+
ACL: 'private'
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async uploadFile(sts, clip, file, fileType, extension) {
|
|
66
|
+
const logExit = logFunctionCall('uploadFile', { fileType, extension, file });
|
|
67
|
+
|
|
68
|
+
logger.info(`Starting upload of ${fileType}`, {
|
|
69
|
+
file: path.basename(file),
|
|
70
|
+
fileType,
|
|
71
|
+
extension,
|
|
72
|
+
clipId: clip.id
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const client = this.createS3Client(sts);
|
|
76
|
+
const uploadParams = this.generateUploadParams(sts, fileType, extension);
|
|
77
|
+
|
|
78
|
+
// Get file stats for logging
|
|
79
|
+
const fileStats = fs.statSync(file);
|
|
80
|
+
logger.verbose('File upload details', {
|
|
81
|
+
fileSizeBytes: fileStats.size,
|
|
82
|
+
fileSizeMB: (fileStats.size / (1024 * 1024)).toFixed(2),
|
|
83
|
+
bucket: sts.bucket,
|
|
84
|
+
key: uploadParams.Key,
|
|
85
|
+
contentType: uploadParams.ContentType
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fileStream = fs.createReadStream(file);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const upload = new Upload({
|
|
92
|
+
client,
|
|
93
|
+
params: {
|
|
94
|
+
...uploadParams,
|
|
95
|
+
Body: fileStream
|
|
96
|
+
},
|
|
97
|
+
partSize: 20 * 1024 * 1024, // 20 MB
|
|
98
|
+
queueSize: 5
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
upload.on('httpUploadProgress', (progress) => {
|
|
102
|
+
if (progress.loaded && progress.total) {
|
|
103
|
+
const percent = Math.round((progress.loaded / progress.total) * 100);
|
|
104
|
+
const speedMBps = progress.loaded / (1024 * 1024) / ((Date.now() - upload.startTime) / 1000);
|
|
105
|
+
|
|
106
|
+
if (percent % 10 === 0) { // Log every 10%
|
|
107
|
+
logger.verbose(`Upload ${fileType} progress: ${percent}%`, {
|
|
108
|
+
loaded: progress.loaded,
|
|
109
|
+
total: progress.total,
|
|
110
|
+
speedMBps: speedMBps.toFixed(2)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Call progress callback if registered
|
|
115
|
+
const callbacks = this.uploadCallbacks.get(clip.id);
|
|
116
|
+
if (callbacks?.onProgress) {
|
|
117
|
+
callbacks.onProgress(percent);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
upload.startTime = Date.now();
|
|
123
|
+
const result = await upload.done();
|
|
124
|
+
const uploadDuration = (Date.now() - upload.startTime) / 1000;
|
|
125
|
+
|
|
126
|
+
if (extension !== 'png') {
|
|
127
|
+
logger.info(`Successfully uploaded ${fileType}`, {
|
|
128
|
+
key: result.Key,
|
|
129
|
+
location: result.Location,
|
|
130
|
+
duration: `${uploadDuration.toFixed(1)}s`,
|
|
131
|
+
averageSpeed: `${(fileStats.size / (1024 * 1024) / uploadDuration).toFixed(2)} MB/s`
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Call complete callback if registered
|
|
135
|
+
const callbacks = this.uploadCallbacks.get(clip.id);
|
|
136
|
+
if (callbacks?.onComplete) {
|
|
137
|
+
callbacks.onComplete(result);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Don't delete files here - let the main upload function handle cleanup
|
|
142
|
+
logExit();
|
|
143
|
+
return result;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger.error('Upload error:', {
|
|
146
|
+
fileType,
|
|
147
|
+
file: path.basename(file),
|
|
148
|
+
error: error.message,
|
|
149
|
+
stack: error.stack
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Don't delete files on error - let the main upload function handle cleanup
|
|
153
|
+
logExit();
|
|
154
|
+
throw error;
|
|
155
|
+
} finally {
|
|
156
|
+
fileStream.destroy();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Methods that match the desktop app's interface
|
|
161
|
+
async uploadVideo(meta, sts, clip) {
|
|
162
|
+
const file = clip.file;
|
|
163
|
+
await this.uploadFile(sts, clip, file, 'video', 'webm');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async uploadLog(app, sts, clip) {
|
|
167
|
+
const file = app.trimmedFileLocation;
|
|
168
|
+
await this.uploadFile(sts, clip, file, 'log', 'jsonl');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Register callbacks for progress and completion
|
|
172
|
+
registerCallbacks(clipId, { onProgress, onComplete }) {
|
|
173
|
+
this.uploadCallbacks.set(clipId, { onProgress, onComplete });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Remove callbacks
|
|
177
|
+
clearCallbacks(clipId) {
|
|
178
|
+
this.uploadCallbacks.delete(clipId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Create a singleton instance
|
|
183
|
+
const uploader = new Uploader();
|
|
184
|
+
|
|
185
|
+
// Export a simplified upload function for CLI use
|
|
186
|
+
export async function upload(filePath, metadata = {}) {
|
|
187
|
+
const logExit = logFunctionCall('upload', { filePath, metadata });
|
|
188
|
+
|
|
189
|
+
const extension = path.extname(filePath).substring(1);
|
|
190
|
+
const fileType = extension === 'webm' ? 'video' : 'log';
|
|
191
|
+
|
|
192
|
+
// Get current date for default title if none provided
|
|
193
|
+
const defaultTitle = `Recording ${new Date().toLocaleString()}`;
|
|
194
|
+
|
|
195
|
+
logger.info('Starting upload process', {
|
|
196
|
+
filePath: path.basename(filePath),
|
|
197
|
+
fileType,
|
|
198
|
+
extension,
|
|
199
|
+
title: metadata.title || defaultTitle,
|
|
200
|
+
hasProject: !!metadata.project
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Handle project ID - use provided project or fetch first available project
|
|
204
|
+
let projectId = metadata.project;
|
|
205
|
+
if (!projectId) {
|
|
206
|
+
logger.debug('No project ID provided, fetching user projects...');
|
|
207
|
+
try {
|
|
208
|
+
const projects = await auth.getProjects();
|
|
209
|
+
if (projects && projects.length > 0) {
|
|
210
|
+
projectId = projects[0].id;
|
|
211
|
+
logger.info('Automatically selected first project', {
|
|
212
|
+
projectId,
|
|
213
|
+
projectName: projects[0].name || 'Unknown'
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
logger.warn('No projects found for user, proceeding without project ID');
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
logger.warn('Failed to fetch projects, proceeding without project ID', {
|
|
220
|
+
error: error.message
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
logger.debug('Using provided project ID', { projectId });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// First, create a replay in the cloud (like the desktop app does)
|
|
228
|
+
const replayConfig = {
|
|
229
|
+
duration: metadata.duration || 0,
|
|
230
|
+
apps: metadata.apps && metadata.apps.length > 0 ? metadata.apps : ['Screen Recording'], // Use tracked apps or fallback
|
|
231
|
+
title: metadata.title || defaultTitle,
|
|
232
|
+
system: {
|
|
233
|
+
platform: process.platform,
|
|
234
|
+
arch: process.arch,
|
|
235
|
+
nodeVersion: process.version
|
|
236
|
+
},
|
|
237
|
+
clientStartDate: metadata.clientStartDate || Date.now() // Use actual recording start time
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Add project if we have one
|
|
241
|
+
if (projectId) {
|
|
242
|
+
replayConfig.project = projectId;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (metadata.description) {
|
|
246
|
+
replayConfig.description = metadata.description;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
logger.verbose('Creating replay with config', replayConfig);
|
|
250
|
+
|
|
251
|
+
logger.info('Creating replay', replayConfig);
|
|
252
|
+
|
|
253
|
+
// Create the replay first
|
|
254
|
+
const token = await auth.getToken();
|
|
255
|
+
|
|
256
|
+
let newReplay;
|
|
257
|
+
try {
|
|
258
|
+
newReplay = await got.post('https://api.testdriver.ai/api/v1/replay', {
|
|
259
|
+
headers: {
|
|
260
|
+
Authorization: `Bearer ${token}`
|
|
261
|
+
},
|
|
262
|
+
json: replayConfig,
|
|
263
|
+
timeout: 30000
|
|
264
|
+
}).json();
|
|
265
|
+
|
|
266
|
+
logger.info('Replay created successfully', {
|
|
267
|
+
replayId: newReplay.replay.id,
|
|
268
|
+
shareKey: newReplay.replay.shareKey,
|
|
269
|
+
shareLink: newReplay.replay.shareLink
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.error('Failed to create replay', {
|
|
273
|
+
status: error.response?.statusCode,
|
|
274
|
+
statusText: error.response?.statusMessage,
|
|
275
|
+
body: error.response?.body,
|
|
276
|
+
replayConfig: replayConfig
|
|
277
|
+
});
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Create a clip object that matches what the desktop app expects
|
|
282
|
+
const clip = {
|
|
283
|
+
id: Date.now().toString(),
|
|
284
|
+
file: filePath,
|
|
285
|
+
title: metadata.title || defaultTitle,
|
|
286
|
+
description: metadata.description || '',
|
|
287
|
+
project: projectId || undefined,
|
|
288
|
+
duration: metadata.duration || 0,
|
|
289
|
+
clientStartDate: metadata.clientStartDate || Date.now() // Use actual recording start time
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Get STS credentials with replay data (like the desktop app)
|
|
293
|
+
const replayData = {
|
|
294
|
+
id: newReplay.replay.id,
|
|
295
|
+
duration: metadata.duration || 0,
|
|
296
|
+
apps: metadata.apps && metadata.apps.length > 0 ? metadata.apps : ['Screen Recording'], // Use tracked apps or fallback
|
|
297
|
+
title: metadata.title || defaultTitle,
|
|
298
|
+
icons: metadata.icons || [] // Include icons metadata for STS token generation
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Add project if we have one
|
|
302
|
+
if (projectId) {
|
|
303
|
+
replayData.project = projectId;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
logger.verbose('Getting STS credentials for replay', { replayId: newReplay.replay.id });
|
|
307
|
+
const sts = await auth.getStsCredentials(replayData);
|
|
308
|
+
|
|
309
|
+
logger.verbose('STS credentials received', {
|
|
310
|
+
hasVideo: !!sts.video,
|
|
311
|
+
hasImage: !!sts.image,
|
|
312
|
+
hasGif: !!sts.gif
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Upload all assets
|
|
316
|
+
const promises = [
|
|
317
|
+
// Upload the main video as mp4 (even though it's actually webm)
|
|
318
|
+
uploader.uploadFile(sts.video, clip, filePath, 'video', 'mp4')
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
// Track files to cleanup after successful upload
|
|
322
|
+
const filesToCleanup = [filePath];
|
|
323
|
+
|
|
324
|
+
// Upload GIF if available
|
|
325
|
+
if (metadata.gifPath && fs.existsSync(metadata.gifPath)) {
|
|
326
|
+
logger.debug('Adding GIF upload to queue', { gifPath: metadata.gifPath });
|
|
327
|
+
promises.push(uploader.uploadFile(sts.gif, clip, metadata.gifPath, 'image', 'gif'));
|
|
328
|
+
filesToCleanup.push(metadata.gifPath);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Upload snapshot if available
|
|
332
|
+
if (metadata.snapshotPath && fs.existsSync(metadata.snapshotPath)) {
|
|
333
|
+
logger.debug('Adding snapshot upload to queue', { snapshotPath: metadata.snapshotPath });
|
|
334
|
+
promises.push(uploader.uploadFile(sts.image, clip, metadata.snapshotPath, 'image', 'png'));
|
|
335
|
+
filesToCleanup.push(metadata.snapshotPath);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
logger.info('Starting asset uploads', { totalUploads: promises.length });
|
|
339
|
+
|
|
340
|
+
// Process and upload logs if available
|
|
341
|
+
if (metadata.logs && metadata.logs.length > 0) {
|
|
342
|
+
logger.debug('Processing logs for upload', { logCount: metadata.logs.length });
|
|
343
|
+
|
|
344
|
+
// Import trimLogs function
|
|
345
|
+
const { trimLogs } = await import('./logs/index.js');
|
|
346
|
+
|
|
347
|
+
// Trim logs to recording duration
|
|
348
|
+
const recordingStartTime = metadata.clientStartDate || Date.now();
|
|
349
|
+
const recordingEndTime = recordingStartTime + (metadata.duration || 0);
|
|
350
|
+
|
|
351
|
+
logger.debug('Trimming logs', {
|
|
352
|
+
startTime: recordingStartTime,
|
|
353
|
+
endTime: recordingEndTime,
|
|
354
|
+
duration: metadata.duration
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const trimmedLogs = await trimLogs(
|
|
358
|
+
metadata.logs,
|
|
359
|
+
0, // startMS (relative to recording start)
|
|
360
|
+
metadata.duration || 0, // endMS
|
|
361
|
+
recordingStartTime, // clientStartDate
|
|
362
|
+
newReplay.replay.id // clipId
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
logger.debug('Logs trimmed', {
|
|
366
|
+
trimmedCount: trimmedLogs.length,
|
|
367
|
+
logsWithContent: trimmedLogs.filter(log => log.count > 0).length
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Upload each log file that has content
|
|
371
|
+
for (const logStatus of trimmedLogs) {
|
|
372
|
+
if (logStatus.count > 0 && logStatus.trimmedFileLocation && fs.existsSync(logStatus.trimmedFileLocation)) {
|
|
373
|
+
try {
|
|
374
|
+
// Use the name from the status, or a default descriptive name
|
|
375
|
+
// The name is what shows in the "App" dropdown, not the file path
|
|
376
|
+
let logName = logStatus.name || 'File Logs';
|
|
377
|
+
|
|
378
|
+
logger.debug('Creating log STS credentials', {
|
|
379
|
+
name: logName,
|
|
380
|
+
type: logStatus.type,
|
|
381
|
+
count: logStatus.count
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const logSts = await auth.createLogSts(
|
|
385
|
+
newReplay.replay.id,
|
|
386
|
+
logStatus.id || `log-${Date.now()}`,
|
|
387
|
+
logName,
|
|
388
|
+
logStatus.type || 'application'
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
logger.debug('Uploading log file', {
|
|
392
|
+
file: path.basename(logStatus.trimmedFileLocation),
|
|
393
|
+
size: fs.statSync(logStatus.trimmedFileLocation).size
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
promises.push(
|
|
397
|
+
uploader.uploadFile(logSts, clip, logStatus.trimmedFileLocation, 'log', 'jsonl')
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Add to cleanup list
|
|
401
|
+
filesToCleanup.push(logStatus.trimmedFileLocation);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
logger.warn('Failed to upload log', {
|
|
404
|
+
logId: logStatus.id,
|
|
405
|
+
error: error.message
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
logger.info('Added log uploads to queue', {
|
|
412
|
+
totalUploads: promises.length,
|
|
413
|
+
logUploads: promises.length - (metadata.gifPath ? 2 : 1) - (metadata.snapshotPath ? 1 : 0)
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await Promise.all(promises);
|
|
418
|
+
|
|
419
|
+
// Clean up uploaded files after all uploads complete successfully
|
|
420
|
+
logger.debug('Cleaning up uploaded files', { files: filesToCleanup.map(f => path.basename(f)) });
|
|
421
|
+
|
|
422
|
+
for (const file of filesToCleanup) {
|
|
423
|
+
try {
|
|
424
|
+
fs.unlinkSync(file);
|
|
425
|
+
logger.debug(`Deleted uploaded file: ${path.basename(file)}`);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
logger.warn(`Failed to delete file: ${path.basename(file)}`, { error: err.message });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Publish the replay (like the desktop app does)
|
|
432
|
+
logger.debug('Publishing replay...');
|
|
433
|
+
await got.post('https://api.testdriver.ai/api/v1/replay/publish', {
|
|
434
|
+
headers: {
|
|
435
|
+
Authorization: `Bearer ${token}`
|
|
436
|
+
},
|
|
437
|
+
json: { id: newReplay.replay.id },
|
|
438
|
+
timeout: 30000
|
|
439
|
+
}).json();
|
|
440
|
+
|
|
441
|
+
logger.info('Upload process completed successfully', {
|
|
442
|
+
replayId: newReplay.replay.id,
|
|
443
|
+
shareLink: newReplay.replay.shareLink
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
logExit();
|
|
447
|
+
|
|
448
|
+
const shareLink = newReplay.replay.shareLink;
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
replay: newReplay.replay,
|
|
452
|
+
shareLink: shareLink
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export { uploader };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { logger } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
// Throttled logging to prevent spam
|
|
6
|
+
const throttledLog = (() => {
|
|
7
|
+
const cache = {};
|
|
8
|
+
const LOG_THROTTLE_DURATION = 500;
|
|
9
|
+
|
|
10
|
+
return (level, msg, ...args) => {
|
|
11
|
+
if (!logger[level]) level = 'info';
|
|
12
|
+
if (!cache[level]) cache[level] = {};
|
|
13
|
+
if (cache[level][msg]) return;
|
|
14
|
+
cache[level][msg] = true;
|
|
15
|
+
setTimeout(() => {
|
|
16
|
+
delete cache[level][msg];
|
|
17
|
+
}, LOG_THROTTLE_DURATION);
|
|
18
|
+
logger[level](msg, ...args);
|
|
19
|
+
};
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
export const jsonl = {
|
|
23
|
+
append: (file, json) => {
|
|
24
|
+
if (!fs.existsSync(file)) {
|
|
25
|
+
try {
|
|
26
|
+
let fd = fs.openSync(file, 'w');
|
|
27
|
+
fs.closeSync(fd);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throttledLog('info', `jsonl.js failed to initialize file ${error}`, {
|
|
30
|
+
json,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
fs.appendFileSync(file, JSON.stringify(json) + '\n', 'utf8');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throttledLog('info', `jsonl.js failed to append to file ${error}`, {
|
|
38
|
+
json,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return file;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
read: (file) => {
|
|
46
|
+
if (!fs.existsSync(file)) {
|
|
47
|
+
return false;
|
|
48
|
+
} else {
|
|
49
|
+
return fs
|
|
50
|
+
.readFileSync(file, 'utf8')
|
|
51
|
+
.split('\n')
|
|
52
|
+
.slice(0, -1)
|
|
53
|
+
.map(JSON.parse);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
write: (directory, fileName, arrayOfJsonObjects) => {
|
|
58
|
+
const file = path.join(directory, fileName);
|
|
59
|
+
|
|
60
|
+
if (!fs.existsSync(file)) {
|
|
61
|
+
try {
|
|
62
|
+
let fd = fs.openSync(file, 'w');
|
|
63
|
+
fs.closeSync(fd);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throttledLog('info', `jsonl.js failed to initialize file ${error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
let data = arrayOfJsonObjects.map((x) => JSON.stringify(x)).join('\n');
|
|
70
|
+
fs.writeFileSync(file, data);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throttledLog('info', `jsonl.js failed to write to file ${error}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return file;
|
|
76
|
+
},
|
|
77
|
+
};
|