screenci 0.0.62 → 0.0.63
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 +15 -4
- package/bin/screenci.js +2 -2
- package/dist/cli.d.ts +29 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +310 -207
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +51 -20
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +17 -6
- package/dist/docs/manifest.js.map +1 -1
- package/dist/e2e/instrument.e2e.js +11 -11
- package/dist/e2e/instrument.e2e.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +27 -0
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +46 -0
- package/dist/src/asset.js.map +1 -1
- package/dist/src/changeFocus.d.ts.map +1 -1
- package/dist/src/changeFocus.js +3 -3
- package/dist/src/changeFocus.js.map +1 -1
- package/dist/src/cue.d.ts +60 -13
- package/dist/src/cue.d.ts.map +1 -1
- package/dist/src/cue.js +153 -47
- package/dist/src/cue.js.map +1 -1
- package/dist/src/events.d.ts +56 -8
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +47 -1
- package/dist/src/events.js.map +1 -1
- package/dist/src/git.d.ts +15 -0
- package/dist/src/git.d.ts.map +1 -0
- package/dist/src/git.js +43 -0
- package/dist/src/git.js.map +1 -0
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +1 -4
- package/dist/src/init.js.map +1 -1
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +49 -125
- package/dist/src/instrument.js.map +1 -1
- package/dist/src/mouse.d.ts +1 -0
- package/dist/src/mouse.d.ts.map +1 -1
- package/dist/src/mouse.js +9 -3
- package/dist/src/mouse.js.map +1 -1
- package/dist/src/recording.d.ts +1 -1
- package/dist/src/recording.d.ts.map +1 -1
- package/dist/src/recordingData.d.ts +43 -1
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/studio.d.ts +36 -0
- package/dist/src/studio.d.ts.map +1 -0
- package/dist/src/studio.js +39 -0
- package/dist/src/studio.js.map +1 -0
- package/dist/src/types.d.ts +141 -125
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -0
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +2 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js.map +1 -1
- package/dist/src/voices.d.ts +3 -3
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/skills/screenci/SKILL.md +4 -7
- package/skills/screenci/references/init.md +1 -2
- package/skills/screenci/references/record.md +2 -9
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { createReadStream } from 'fs';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
|
|
4
|
-
import { createHash } from 'crypto';
|
|
5
|
-
import {
|
|
4
|
+
import { createHash, randomUUID } from 'crypto';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
6
|
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises';
|
|
7
7
|
import { delimiter, dirname, relative as pathRelative, resolve } from 'path';
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
|
-
import { confirm } from '@inquirer/prompts';
|
|
10
9
|
import { Command, CommanderError } from 'commander';
|
|
11
10
|
import pc from 'picocolors';
|
|
12
11
|
import { logger } from './src/logger.js';
|
|
@@ -15,8 +14,28 @@ import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } fro
|
|
|
15
14
|
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
|
|
16
15
|
import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
|
|
17
16
|
const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
|
|
18
|
-
const
|
|
19
|
-
const
|
|
17
|
+
const SCREENCI_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-record';
|
|
18
|
+
const SCREENCI_ENVIRONMENT_VARIABLE = 'SCREENCI_ENVIRONMENT';
|
|
19
|
+
const SCREENCI_ENVIRONMENT_OPTION_VALUES = ['local', 'dev', 'prod'];
|
|
20
|
+
const SCREENCI_PRODUCTION_BACKEND_URL = 'https://api.screenci.com';
|
|
21
|
+
const SCREENCI_PRODUCTION_FRONTEND_URL = 'https://app.screenci.com';
|
|
22
|
+
const SCREENCI_DEVELOPMENT_BACKEND_URL = 'https://dev.api.screenci.com';
|
|
23
|
+
const SCREENCI_DEVELOPMENT_FRONTEND_URL = 'https://dev.app.screenci.com';
|
|
24
|
+
const SCREENCI_LINK_SESSION_FILE = 'link-session.json';
|
|
25
|
+
const SCREENCI_LINK_SESSION_POLL_INTERVAL_MS = 2_000;
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
function parseScreenCIEnvironment(value) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (SCREENCI_ENVIRONMENT_OPTION_VALUES.includes(value)) {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Invalid ${SCREENCI_ENVIRONMENT_VARIABLE} "${value}". Expected one of: ${SCREENCI_ENVIRONMENT_OPTION_VALUES.join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
function getScreenCIEnvironment() {
|
|
36
|
+
const parsed = parseScreenCIEnvironment(process.env[SCREENCI_ENVIRONMENT_VARIABLE]);
|
|
37
|
+
return parsed ?? 'prod';
|
|
38
|
+
}
|
|
20
39
|
export function collectPlaywrightListTitles(suites) {
|
|
21
40
|
const titles = [];
|
|
22
41
|
const visitSuite = (suite) => {
|
|
@@ -51,7 +70,7 @@ function extractPlaywrightDiscoveryError(output) {
|
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
function logScreenCISecretGuide() {
|
|
54
|
-
logger.info(`Guide: ${pc.cyan(
|
|
73
|
+
logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`);
|
|
55
74
|
}
|
|
56
75
|
function getSuggestedScreenciCommand(command) {
|
|
57
76
|
const pm = determinePackageManager();
|
|
@@ -70,7 +89,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
|
70
89
|
'--list',
|
|
71
90
|
'--reporter=json',
|
|
72
91
|
];
|
|
73
|
-
const spawnSpec =
|
|
92
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
|
|
74
93
|
return await new Promise((resolve, reject) => {
|
|
75
94
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
76
95
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
@@ -181,6 +200,28 @@ function contentTypeForPath(filePath) {
|
|
|
181
200
|
};
|
|
182
201
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
183
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* One-line summary of Studio overrides for CLI output, e.g.
|
|
205
|
+
* `recording.size (1 → 0.8), narration "intro" (en)`.
|
|
206
|
+
*/
|
|
207
|
+
export function formatStudioChangeSummary(changes) {
|
|
208
|
+
return changes
|
|
209
|
+
.map((change) => {
|
|
210
|
+
const label = change.label ?? 'selection';
|
|
211
|
+
if (change.kind === 'narration') {
|
|
212
|
+
return change.language !== undefined
|
|
213
|
+
? `${label} (${change.language})`
|
|
214
|
+
: label;
|
|
215
|
+
}
|
|
216
|
+
return change.from !== undefined && change.to !== undefined
|
|
217
|
+
? `${label} (${change.from} → ${change.to})`
|
|
218
|
+
: label;
|
|
219
|
+
})
|
|
220
|
+
.join(', ');
|
|
221
|
+
}
|
|
222
|
+
export function formatStudioUrl(appUrl, projectId, videoId) {
|
|
223
|
+
return `${appUrl}/project/${projectId}/video/${videoId}/studio`;
|
|
224
|
+
}
|
|
184
225
|
class UploadAssetError extends Error {
|
|
185
226
|
constructor(message) {
|
|
186
227
|
super(message);
|
|
@@ -199,6 +240,14 @@ class PartialUploadError extends Error {
|
|
|
199
240
|
this.name = 'PartialUploadError';
|
|
200
241
|
}
|
|
201
242
|
}
|
|
243
|
+
class RecordFailureHintError extends Error {
|
|
244
|
+
cause;
|
|
245
|
+
constructor(cause) {
|
|
246
|
+
super(cause.message);
|
|
247
|
+
this.name = cause.name;
|
|
248
|
+
this.cause = cause;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
202
251
|
function isUploadCancelledError(err) {
|
|
203
252
|
return (err instanceof UploadCancelledError ||
|
|
204
253
|
(err instanceof Error &&
|
|
@@ -207,12 +256,12 @@ function isUploadCancelledError(err) {
|
|
|
207
256
|
function isPartialUploadError(err) {
|
|
208
257
|
return err instanceof PartialUploadError;
|
|
209
258
|
}
|
|
259
|
+
function isRecordFailureHintError(err) {
|
|
260
|
+
return err instanceof RecordFailureHintError;
|
|
261
|
+
}
|
|
210
262
|
function isUploadAssetError(err) {
|
|
211
263
|
return err instanceof UploadAssetError;
|
|
212
264
|
}
|
|
213
|
-
function supportsInPlaceUploadUpdates(verbose) {
|
|
214
|
-
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
215
|
-
}
|
|
216
265
|
function formatUploadProgressLine(videoName, status) {
|
|
217
266
|
switch (status) {
|
|
218
267
|
case undefined:
|
|
@@ -229,41 +278,13 @@ function formatUploadProgressLine(videoName, status) {
|
|
|
229
278
|
}
|
|
230
279
|
}
|
|
231
280
|
}
|
|
232
|
-
function createUploadProgressReporter(videoNames,
|
|
233
|
-
const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
|
|
234
|
-
if (!useInPlaceUpdates) {
|
|
235
|
-
return {
|
|
236
|
-
complete(index, status) {
|
|
237
|
-
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
238
|
-
},
|
|
239
|
-
info(message) {
|
|
240
|
-
logger.info(message);
|
|
241
|
-
},
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
const statuses = new Array(videoNames.length);
|
|
245
|
-
let hasRendered = false;
|
|
246
|
-
const render = () => {
|
|
247
|
-
const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
|
|
248
|
-
process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
|
|
249
|
-
hasRendered = true;
|
|
250
|
-
};
|
|
251
|
-
const clear = () => {
|
|
252
|
-
if (!hasRendered || videoNames.length === 0)
|
|
253
|
-
return;
|
|
254
|
-
process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
|
|
255
|
-
hasRendered = false;
|
|
256
|
-
};
|
|
257
|
-
render();
|
|
281
|
+
function createUploadProgressReporter(videoNames, _verbose) {
|
|
258
282
|
return {
|
|
259
283
|
complete(index, status) {
|
|
260
|
-
|
|
261
|
-
render();
|
|
284
|
+
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
262
285
|
},
|
|
263
286
|
info(message) {
|
|
264
|
-
clear();
|
|
265
287
|
logger.info(message);
|
|
266
|
-
render();
|
|
267
288
|
},
|
|
268
289
|
};
|
|
269
290
|
}
|
|
@@ -305,9 +326,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
305
326
|
preparedUploadAssets,
|
|
306
327
|
};
|
|
307
328
|
}
|
|
308
|
-
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
|
|
329
|
+
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, progressIndex, recordId) {
|
|
309
330
|
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
310
331
|
let projectId = null;
|
|
332
|
+
let videoId = null;
|
|
311
333
|
try {
|
|
312
334
|
uploadAbort.throwIfAborted();
|
|
313
335
|
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
@@ -315,9 +337,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
315
337
|
progressReporter.complete(progressIndex, 'failure');
|
|
316
338
|
return {
|
|
317
339
|
projectId: null,
|
|
340
|
+
videoId: null,
|
|
318
341
|
hadFailure: true,
|
|
319
342
|
videoName,
|
|
320
343
|
failureMessage: `Missing recording.mp4 for "${videoName}"`,
|
|
344
|
+
recordId,
|
|
321
345
|
};
|
|
322
346
|
}
|
|
323
347
|
const recordingHash = await hashFile(recordingPath);
|
|
@@ -326,12 +350,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
326
350
|
headers: {
|
|
327
351
|
'Content-Type': 'application/json',
|
|
328
352
|
'X-ScreenCI-Secret': secret,
|
|
353
|
+
...(elevenLabsApiKey
|
|
354
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
355
|
+
: {}),
|
|
329
356
|
},
|
|
330
357
|
body: JSON.stringify({
|
|
331
358
|
projectName,
|
|
332
359
|
videoName,
|
|
333
360
|
data,
|
|
334
361
|
recordingHash,
|
|
362
|
+
recordId,
|
|
335
363
|
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
336
364
|
fileHash: asset.fileHash,
|
|
337
365
|
size: asset.size,
|
|
@@ -349,14 +377,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
349
377
|
progressReporter.complete(progressIndex, 'failure');
|
|
350
378
|
return {
|
|
351
379
|
projectId: null,
|
|
380
|
+
videoId: null,
|
|
352
381
|
hadFailure: true,
|
|
353
382
|
videoName,
|
|
354
383
|
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
384
|
+
recordId,
|
|
355
385
|
};
|
|
356
386
|
}
|
|
357
387
|
const startBody = (await startResponse.json());
|
|
358
388
|
const { recordingId } = startBody;
|
|
359
389
|
projectId = startBody.projectId;
|
|
390
|
+
videoId = startBody.videoId ?? null;
|
|
391
|
+
const studio = startBody.studio;
|
|
360
392
|
if (verbose) {
|
|
361
393
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
362
394
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -381,6 +413,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
381
413
|
'Content-Type': 'video/mp4',
|
|
382
414
|
'Content-Length': String(fileStat.size),
|
|
383
415
|
'X-ScreenCI-Secret': secret,
|
|
416
|
+
...(elevenLabsApiKey
|
|
417
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
418
|
+
: {}),
|
|
384
419
|
},
|
|
385
420
|
body: stream,
|
|
386
421
|
signal: uploadAbort.signal,
|
|
@@ -392,9 +427,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
392
427
|
progressReporter.complete(progressIndex, 'failure');
|
|
393
428
|
return {
|
|
394
429
|
projectId,
|
|
430
|
+
videoId,
|
|
395
431
|
hadFailure: true,
|
|
396
432
|
videoName,
|
|
397
433
|
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
434
|
+
recordId,
|
|
398
435
|
};
|
|
399
436
|
}
|
|
400
437
|
}
|
|
@@ -403,7 +440,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
403
440
|
}
|
|
404
441
|
progressReporter.complete(progressIndex, 'success');
|
|
405
442
|
cleanupUploadedRecordingDir(screenciDir, entry);
|
|
406
|
-
return {
|
|
443
|
+
return {
|
|
444
|
+
projectId,
|
|
445
|
+
videoId,
|
|
446
|
+
hadFailure: false,
|
|
447
|
+
videoName,
|
|
448
|
+
recordId,
|
|
449
|
+
...(studio !== undefined && { studio }),
|
|
450
|
+
};
|
|
407
451
|
}
|
|
408
452
|
catch (err) {
|
|
409
453
|
if (isUploadCancelledError(err)) {
|
|
@@ -414,17 +458,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
414
458
|
progressReporter.complete(progressIndex, 'failure');
|
|
415
459
|
return {
|
|
416
460
|
projectId,
|
|
461
|
+
videoId,
|
|
417
462
|
hadFailure: true,
|
|
418
463
|
videoName,
|
|
419
464
|
failureMessage: err instanceof Error ? err.message : String(err),
|
|
465
|
+
recordId,
|
|
420
466
|
};
|
|
421
467
|
}
|
|
422
468
|
progressReporter.complete(progressIndex, 'failure');
|
|
423
469
|
return {
|
|
424
470
|
projectId,
|
|
471
|
+
videoId,
|
|
425
472
|
hadFailure: true,
|
|
426
473
|
videoName,
|
|
427
474
|
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
475
|
+
recordId,
|
|
428
476
|
};
|
|
429
477
|
}
|
|
430
478
|
}
|
|
@@ -527,6 +575,15 @@ function resolveWindowsCmdShim(cmd) {
|
|
|
527
575
|
}
|
|
528
576
|
return shimName;
|
|
529
577
|
}
|
|
578
|
+
function resolvePlaywrightSpawnSpec(args, searchFrom) {
|
|
579
|
+
const cliEntrypoint = require.resolve('@playwright/test/cli', {
|
|
580
|
+
paths: [searchFrom],
|
|
581
|
+
});
|
|
582
|
+
return {
|
|
583
|
+
command: process.execPath,
|
|
584
|
+
args: [cliEntrypoint, ...args],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
530
587
|
function forwardChildSignals(child, activityLabel, options = {}) {
|
|
531
588
|
let forwardedSignal = null;
|
|
532
589
|
let forceKillTimer = null;
|
|
@@ -594,9 +651,11 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
594
651
|
getForwardedSignal: () => forwardedSignal,
|
|
595
652
|
};
|
|
596
653
|
}
|
|
597
|
-
function
|
|
654
|
+
function clearRecordingDirectories(dir) {
|
|
598
655
|
mkdirSync(dir, { recursive: true });
|
|
599
656
|
for (const entry of readdirSync(dir)) {
|
|
657
|
+
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
658
|
+
continue;
|
|
600
659
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
601
660
|
}
|
|
602
661
|
}
|
|
@@ -692,11 +751,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
692
751
|
}
|
|
693
752
|
return preparedAssets;
|
|
694
753
|
}
|
|
695
|
-
async function collectUploadAssets(data, configDir) {
|
|
754
|
+
export async function collectUploadAssets(data, configDir) {
|
|
696
755
|
const sourceFilePath = data.metadata?.sourceFilePath;
|
|
697
756
|
const assets = new Map();
|
|
698
757
|
for (const event of data.events) {
|
|
699
758
|
if (event.type === 'assetStart') {
|
|
759
|
+
// Studio assets have no local file — they are uploaded from the Studio
|
|
760
|
+
// page and merged into the recording by the backend.
|
|
761
|
+
if ('studio' in event && event.studio === true)
|
|
762
|
+
continue;
|
|
700
763
|
if (assets.has(`name:${event.name}`))
|
|
701
764
|
continue;
|
|
702
765
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
@@ -952,6 +1015,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
952
1015
|
}
|
|
953
1016
|
export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
954
1017
|
const uploadAbort = createUploadAbortController('upload');
|
|
1018
|
+
const recordId = randomUUID();
|
|
955
1019
|
let entries;
|
|
956
1020
|
try {
|
|
957
1021
|
entries = await readdir(screenciDir);
|
|
@@ -960,15 +1024,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
960
1024
|
logger.warn('No .screenci directory found, skipping upload');
|
|
961
1025
|
return {
|
|
962
1026
|
projectId: null,
|
|
1027
|
+
recordId: null,
|
|
963
1028
|
hadFailures: false,
|
|
964
1029
|
failedVideoNames: [],
|
|
965
1030
|
failedVideoMessages: [],
|
|
1031
|
+
studioNotices: [],
|
|
966
1032
|
};
|
|
967
1033
|
}
|
|
968
1034
|
if (specificEntry !== undefined) {
|
|
969
1035
|
entries = entries.filter((e) => e === specificEntry);
|
|
970
1036
|
}
|
|
971
1037
|
let firstProjectId = null;
|
|
1038
|
+
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
972
1039
|
try {
|
|
973
1040
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
974
1041
|
uploadAbort.throwIfAborted();
|
|
@@ -977,13 +1044,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
977
1044
|
if (candidates.length === 0) {
|
|
978
1045
|
return {
|
|
979
1046
|
projectId: null,
|
|
1047
|
+
recordId: null,
|
|
980
1048
|
hadFailures: false,
|
|
981
1049
|
failedVideoNames: [],
|
|
982
1050
|
failedVideoMessages: [],
|
|
1051
|
+
studioNotices: [],
|
|
983
1052
|
};
|
|
984
1053
|
}
|
|
985
1054
|
const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
|
|
986
|
-
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index)));
|
|
1055
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId)));
|
|
987
1056
|
firstProjectId =
|
|
988
1057
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
989
1058
|
const hadFailures = results.some((result) => result.hadFailure);
|
|
@@ -993,11 +1062,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
993
1062
|
const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
|
|
994
1063
|
? [{ videoName: result.videoName, message: result.failureMessage }]
|
|
995
1064
|
: []);
|
|
1065
|
+
const studioNotices = results.flatMap((result) => !result.hadFailure && result.studio !== undefined
|
|
1066
|
+
? [
|
|
1067
|
+
{
|
|
1068
|
+
videoName: result.videoName,
|
|
1069
|
+
videoId: result.videoId,
|
|
1070
|
+
studio: result.studio,
|
|
1071
|
+
},
|
|
1072
|
+
]
|
|
1073
|
+
: []);
|
|
996
1074
|
return {
|
|
997
1075
|
projectId: firstProjectId,
|
|
1076
|
+
recordId,
|
|
998
1077
|
hadFailures,
|
|
999
1078
|
failedVideoNames,
|
|
1000
1079
|
failedVideoMessages,
|
|
1080
|
+
studioNotices,
|
|
1001
1081
|
};
|
|
1002
1082
|
}
|
|
1003
1083
|
finally {
|
|
@@ -1015,16 +1095,38 @@ async function countCompletedRecordings(screenciDir) {
|
|
|
1015
1095
|
return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
|
|
1016
1096
|
}
|
|
1017
1097
|
export function getDevBackendUrl() {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1098
|
+
switch (getScreenCIEnvironment()) {
|
|
1099
|
+
case 'local': {
|
|
1100
|
+
const devBackendPort = process.env.DEV_BACKEND_PORT;
|
|
1101
|
+
return devBackendPort
|
|
1102
|
+
? `http://localhost:${devBackendPort}`
|
|
1103
|
+
: 'http://localhost:8787';
|
|
1104
|
+
}
|
|
1105
|
+
case 'dev':
|
|
1106
|
+
return SCREENCI_DEVELOPMENT_BACKEND_URL;
|
|
1107
|
+
case 'prod':
|
|
1108
|
+
return SCREENCI_PRODUCTION_BACKEND_URL;
|
|
1109
|
+
}
|
|
1022
1110
|
}
|
|
1023
1111
|
export function getDevFrontendUrl() {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1112
|
+
switch (getScreenCIEnvironment()) {
|
|
1113
|
+
case 'local': {
|
|
1114
|
+
const devFrontendPort = process.env.DEV_FRONTEND_PORT;
|
|
1115
|
+
return devFrontendPort
|
|
1116
|
+
? `http://localhost:${devFrontendPort}`
|
|
1117
|
+
: 'http://localhost:5173';
|
|
1118
|
+
}
|
|
1119
|
+
case 'dev':
|
|
1120
|
+
return SCREENCI_DEVELOPMENT_FRONTEND_URL;
|
|
1121
|
+
case 'prod':
|
|
1122
|
+
return SCREENCI_PRODUCTION_FRONTEND_URL;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
export function getCliLinkSessionApiUrl() {
|
|
1126
|
+
return getDevFrontendUrl();
|
|
1127
|
+
}
|
|
1128
|
+
function getScreenCISecretsUrl() {
|
|
1129
|
+
return `${getDevFrontendUrl()}/secrets`;
|
|
1028
1130
|
}
|
|
1029
1131
|
async function writeGitHubProjectOutput(projectUrl) {
|
|
1030
1132
|
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
@@ -1234,10 +1336,11 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
|
|
|
1234
1336
|
}
|
|
1235
1337
|
async function requireScreenCISecret(configPath) {
|
|
1236
1338
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1237
|
-
const secret = process.env.SCREENCI_SECRET
|
|
1339
|
+
const secret = process.env.SCREENCI_SECRET ??
|
|
1340
|
+
(await ensureScreenciSecret(resolvedConfigPath));
|
|
1238
1341
|
if (!secret) {
|
|
1239
1342
|
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1240
|
-
logger.error(`No SCREENCI_SECRET configured.
|
|
1343
|
+
logger.error(`No SCREENCI_SECRET configured. Rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} or add SCREENCI_SECRET manually to ${envFilePath} by following the guide at ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}.`);
|
|
1241
1344
|
process.exit(1);
|
|
1242
1345
|
}
|
|
1243
1346
|
return {
|
|
@@ -1281,29 +1384,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1281
1384
|
}
|
|
1282
1385
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1283
1386
|
}
|
|
1284
|
-
function openBrowser(url) {
|
|
1285
|
-
try {
|
|
1286
|
-
if (process.platform === 'win32') {
|
|
1287
|
-
spawn('cmd', ['/c', 'start', '', url], {
|
|
1288
|
-
detached: true,
|
|
1289
|
-
stdio: 'ignore',
|
|
1290
|
-
shell: true,
|
|
1291
|
-
}).unref();
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1295
|
-
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
1296
|
-
}
|
|
1297
|
-
catch (err) {
|
|
1298
|
-
logger.warn('Failed to open browser automatically:', err);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
async function promptToOpenLoginUrlWithSignal(signal) {
|
|
1302
|
-
return await confirm({
|
|
1303
|
-
message: 'Open this link in your browser now?',
|
|
1304
|
-
default: false,
|
|
1305
|
-
}, { signal });
|
|
1306
|
-
}
|
|
1307
1387
|
async function persistScreenCISecret(envFilePath, secret) {
|
|
1308
1388
|
const nextLine = `SCREENCI_SECRET=${secret}`;
|
|
1309
1389
|
try {
|
|
@@ -1330,147 +1410,137 @@ async function persistScreenCISecret(envFilePath, secret) {
|
|
|
1330
1410
|
}
|
|
1331
1411
|
await writeFile(envFilePath, `${nextLine}\n`);
|
|
1332
1412
|
}
|
|
1333
|
-
|
|
1334
|
-
return
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
finish(() => reject(err));
|
|
1366
|
-
}
|
|
1367
|
-
});
|
|
1368
|
-
server.listen(0, '127.0.0.1', () => {
|
|
1369
|
-
const port = server.address().port;
|
|
1370
|
-
const callbackUrl = `http://localhost:${port}/callback`;
|
|
1371
|
-
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
1372
|
-
logger.info(`Open this link to log in to ScreenCI:\n${loginUrl}\n`);
|
|
1373
|
-
void (async () => {
|
|
1374
|
-
if (options?.openBrowserImmediately) {
|
|
1375
|
-
openBrowser(loginUrl);
|
|
1376
|
-
return;
|
|
1377
|
-
}
|
|
1378
|
-
if (settled)
|
|
1379
|
-
return;
|
|
1380
|
-
const shouldOpen = await promptToOpenLoginUrlWithSignal(promptAbort.signal);
|
|
1381
|
-
if (settled)
|
|
1382
|
-
return;
|
|
1383
|
-
if (shouldOpen) {
|
|
1384
|
-
openBrowser(loginUrl);
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
|
|
1388
|
-
})().catch((err) => {
|
|
1389
|
-
if (settled)
|
|
1390
|
-
return;
|
|
1391
|
-
if (err instanceof Error &&
|
|
1392
|
-
(err.name === 'AbortPromptError' ||
|
|
1393
|
-
err.name === 'AbortError' ||
|
|
1394
|
-
err.message === 'Prompt was canceled')) {
|
|
1395
|
-
return;
|
|
1396
|
-
}
|
|
1397
|
-
server.close();
|
|
1398
|
-
finish(() => reject(err));
|
|
1399
|
-
});
|
|
1400
|
-
});
|
|
1401
|
-
const timeout = setTimeout(() => {
|
|
1402
|
-
server.close();
|
|
1403
|
-
finish(() => reject(new Error('Authentication timed out after 15 minutes')));
|
|
1404
|
-
}, 15 * 60 * 1000);
|
|
1405
|
-
server.on('close', () => clearTimeout(timeout));
|
|
1413
|
+
function getLinkSessionFilePath(projectDir) {
|
|
1414
|
+
return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE);
|
|
1415
|
+
}
|
|
1416
|
+
async function readPersistedLinkSessionSpec(specPath) {
|
|
1417
|
+
try {
|
|
1418
|
+
const raw = await readFile(specPath, 'utf-8');
|
|
1419
|
+
return JSON.parse(raw);
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
if (!isMissingFileError(error)) {
|
|
1423
|
+
logger.warn(`Ignoring invalid stored link session at ${specPath}.`);
|
|
1424
|
+
rmSync(specPath, { force: true });
|
|
1425
|
+
}
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
async function writePersistedLinkSessionSpec(specPath, spec) {
|
|
1430
|
+
mkdirSync(dirname(specPath), { recursive: true });
|
|
1431
|
+
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
1432
|
+
}
|
|
1433
|
+
function deletePersistedLinkSessionSpec(specPath) {
|
|
1434
|
+
rmSync(specPath, { force: true });
|
|
1435
|
+
}
|
|
1436
|
+
function isStoredLinkSessionReusable(spec, options) {
|
|
1437
|
+
return (spec.environment === options.environment &&
|
|
1438
|
+
spec.envFilePath === options.envFilePath &&
|
|
1439
|
+
spec.resolvedConfigPath === options.resolvedConfigPath &&
|
|
1440
|
+
spec.expiresAt > new Date().toISOString());
|
|
1441
|
+
}
|
|
1442
|
+
async function createLinkSessionSpec(options) {
|
|
1443
|
+
const response = await fetch(`${options.apiUrl}/cli-link/session`, {
|
|
1444
|
+
method: 'POST',
|
|
1406
1445
|
});
|
|
1446
|
+
if (!response.ok) {
|
|
1447
|
+
const text = await response.text();
|
|
1448
|
+
throw new Error(`Failed to create link session: ${response.status} ${text}`);
|
|
1449
|
+
}
|
|
1450
|
+
const body = (await response.json());
|
|
1451
|
+
return {
|
|
1452
|
+
token: body.token,
|
|
1453
|
+
appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
|
|
1454
|
+
pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
|
|
1455
|
+
createdAt: body.createdAt,
|
|
1456
|
+
expiresAt: body.expiresAt,
|
|
1457
|
+
environment: options.environment,
|
|
1458
|
+
...(options.resolvedConfigPath
|
|
1459
|
+
? { resolvedConfigPath: options.resolvedConfigPath }
|
|
1460
|
+
: {}),
|
|
1461
|
+
envFilePath: options.envFilePath,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
async function pollLinkSession(spec) {
|
|
1465
|
+
for (;;) {
|
|
1466
|
+
const response = await fetch(spec.pollUrl);
|
|
1467
|
+
const body = (await response.json());
|
|
1468
|
+
const status = body.status ?? 'invalid';
|
|
1469
|
+
if (status === 'completed' && body.secret) {
|
|
1470
|
+
return { status, secret: body.secret };
|
|
1471
|
+
}
|
|
1472
|
+
if (status === 'expired' || status === 'consumed' || status === 'invalid') {
|
|
1473
|
+
return { status };
|
|
1474
|
+
}
|
|
1475
|
+
if (new Date().toISOString() >= spec.expiresAt) {
|
|
1476
|
+
return { status: 'expired' };
|
|
1477
|
+
}
|
|
1478
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
|
|
1479
|
+
}
|
|
1407
1480
|
}
|
|
1408
1481
|
export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
1409
1482
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1410
1483
|
if (existingSecret)
|
|
1411
1484
|
return existingSecret;
|
|
1412
|
-
const appUrl = getDevFrontendUrl();
|
|
1413
1485
|
try {
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const savePath = resolvedConfigPath
|
|
1486
|
+
const environment = getScreenCIEnvironment();
|
|
1487
|
+
const apiUrl = getCliLinkSessionApiUrl();
|
|
1488
|
+
const appUrl = getDevFrontendUrl();
|
|
1489
|
+
const envFilePath = resolvedConfigPath
|
|
1419
1490
|
? await resolveProjectEnvFilePath(resolvedConfigPath)
|
|
1420
1491
|
: resolve(process.cwd(), '.env');
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1492
|
+
const projectDir = resolvedConfigPath
|
|
1493
|
+
? dirname(resolvedConfigPath)
|
|
1494
|
+
: process.cwd();
|
|
1495
|
+
const specPath = getLinkSessionFilePath(projectDir);
|
|
1496
|
+
const linkSessionContext = {
|
|
1497
|
+
environment,
|
|
1498
|
+
envFilePath,
|
|
1499
|
+
...(resolvedConfigPath ? { resolvedConfigPath } : {}),
|
|
1500
|
+
};
|
|
1501
|
+
for (;;) {
|
|
1502
|
+
const storedSpec = await readPersistedLinkSessionSpec(specPath);
|
|
1503
|
+
const spec = storedSpec &&
|
|
1504
|
+
isStoredLinkSessionReusable(storedSpec, linkSessionContext)
|
|
1505
|
+
? storedSpec
|
|
1506
|
+
: await createLinkSessionSpec({
|
|
1507
|
+
apiUrl,
|
|
1508
|
+
appUrl,
|
|
1509
|
+
...linkSessionContext,
|
|
1510
|
+
});
|
|
1511
|
+
if (spec !== storedSpec) {
|
|
1512
|
+
await writePersistedLinkSessionSpec(specPath, spec);
|
|
1513
|
+
}
|
|
1514
|
+
logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
|
|
1515
|
+
const result = await pollLinkSession(spec);
|
|
1516
|
+
if (result.status === 'completed' && result.secret) {
|
|
1517
|
+
process.env.SCREENCI_SECRET = result.secret;
|
|
1518
|
+
await persistScreenCISecret(envFilePath, result.secret);
|
|
1519
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
1520
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
|
|
1521
|
+
return result.secret;
|
|
1522
|
+
}
|
|
1523
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
1524
|
+
}
|
|
1424
1525
|
}
|
|
1425
1526
|
catch (err) {
|
|
1426
1527
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1427
1528
|
logger.warn(`Authentication failed: ${msg}`);
|
|
1428
|
-
logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${
|
|
1529
|
+
logger.info(`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`);
|
|
1429
1530
|
logScreenCISecretGuide();
|
|
1430
1531
|
return undefined;
|
|
1431
1532
|
}
|
|
1432
1533
|
}
|
|
1433
|
-
async function runLogin(configPath, open = false) {
|
|
1434
|
-
const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
|
|
1435
|
-
if (process.env.SCREENCI_SECRET) {
|
|
1436
|
-
logger.info('SCREENCI_SECRET is already configured.');
|
|
1437
|
-
return;
|
|
1438
|
-
}
|
|
1439
|
-
const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1440
|
-
const appUrl = getDevFrontendUrl();
|
|
1441
|
-
try {
|
|
1442
|
-
const secret = await performBrowserLogin(appUrl, {
|
|
1443
|
-
openBrowserImmediately: open,
|
|
1444
|
-
});
|
|
1445
|
-
process.env.SCREENCI_SECRET = secret;
|
|
1446
|
-
await persistScreenCISecret(savePath, secret);
|
|
1447
|
-
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1448
|
-
}
|
|
1449
|
-
catch (err) {
|
|
1450
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1451
|
-
logger.warn(`Authentication failed: ${msg}`);
|
|
1452
|
-
logger.info(`You can run ${pc.cyan(getSuggestedScreenciCommand('login'))} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1453
|
-
logScreenCISecretGuide();
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
1534
|
export async function main() {
|
|
1457
1535
|
if (process.argv.length <= 2) {
|
|
1458
1536
|
logger.error('Error: No command provided');
|
|
1459
|
-
logger.error('Available commands:
|
|
1537
|
+
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1460
1538
|
process.exit(1);
|
|
1461
1539
|
}
|
|
1462
1540
|
const program = new Command();
|
|
1463
1541
|
const defaultPackageManager = determinePackageManager();
|
|
1464
1542
|
program.name('screenci');
|
|
1465
1543
|
program.exitOverride();
|
|
1466
|
-
program
|
|
1467
|
-
.command('login')
|
|
1468
|
-
.description('Authenticate and save SCREENCI_SECRET for this project')
|
|
1469
|
-
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1470
|
-
.option('--open', 'open the login URL in your browser immediately')
|
|
1471
|
-
.action(async (options) => {
|
|
1472
|
-
await runLogin(options.config, options.open === true);
|
|
1473
|
-
});
|
|
1474
1544
|
// record command — playwright args pass through as-is
|
|
1475
1545
|
program
|
|
1476
1546
|
.command('record [playwrightArgs...]')
|
|
@@ -1484,13 +1554,13 @@ export async function main() {
|
|
|
1484
1554
|
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1485
1555
|
}
|
|
1486
1556
|
catch (error) {
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
playwrightFailure = error;
|
|
1557
|
+
if (!(error instanceof Error))
|
|
1558
|
+
throw error;
|
|
1559
|
+
if (error.message.startsWith('Playwright exited with code ')) {
|
|
1560
|
+
playwrightFailure = new RecordFailureHintError(error);
|
|
1491
1561
|
}
|
|
1492
1562
|
else {
|
|
1493
|
-
throw error;
|
|
1563
|
+
throw new RecordFailureHintError(error);
|
|
1494
1564
|
}
|
|
1495
1565
|
}
|
|
1496
1566
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
@@ -1514,7 +1584,7 @@ export async function main() {
|
|
|
1514
1584
|
logger.info('All recordings failed.');
|
|
1515
1585
|
}
|
|
1516
1586
|
else if (!secret) {
|
|
1517
|
-
logger.info(`No SCREENCI_SECRET configured for uploads.
|
|
1587
|
+
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
1518
1588
|
}
|
|
1519
1589
|
else if (playwrightFailure !== null &&
|
|
1520
1590
|
uploadPolicy === 'all-or-nothing') {
|
|
@@ -1526,9 +1596,11 @@ export async function main() {
|
|
|
1526
1596
|
}
|
|
1527
1597
|
let uploadResult = {
|
|
1528
1598
|
projectId: null,
|
|
1599
|
+
recordId: null,
|
|
1529
1600
|
hadFailures: false,
|
|
1530
1601
|
failedVideoNames: [],
|
|
1531
1602
|
failedVideoMessages: [],
|
|
1603
|
+
studioNotices: [],
|
|
1532
1604
|
};
|
|
1533
1605
|
try {
|
|
1534
1606
|
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
@@ -1539,8 +1611,17 @@ export async function main() {
|
|
|
1539
1611
|
}
|
|
1540
1612
|
throw err;
|
|
1541
1613
|
}
|
|
1542
|
-
const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
|
|
1543
|
-
if (projectId !== null) {
|
|
1614
|
+
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, } = uploadResult;
|
|
1615
|
+
if (recordId !== null && projectId !== null) {
|
|
1616
|
+
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
1617
|
+
await writeGitHubProjectOutput(recordUrl);
|
|
1618
|
+
logger.info('');
|
|
1619
|
+
logger.info(playwrightFailure !== null
|
|
1620
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1621
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
1622
|
+
logger.info(pc.cyan(recordUrl));
|
|
1623
|
+
}
|
|
1624
|
+
else if (projectId !== null) {
|
|
1544
1625
|
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1545
1626
|
await writeGitHubProjectOutput(projectUrl);
|
|
1546
1627
|
logger.info('');
|
|
@@ -1549,6 +1630,19 @@ export async function main() {
|
|
|
1549
1630
|
: 'Recording finished, rendering in progress. Results available at:');
|
|
1550
1631
|
logger.info(pc.cyan(projectUrl));
|
|
1551
1632
|
}
|
|
1633
|
+
for (const notice of studioNotices) {
|
|
1634
|
+
if ('held' in notice.studio) {
|
|
1635
|
+
logger.info('');
|
|
1636
|
+
logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
|
|
1637
|
+
if (projectId !== null && notice.videoId !== null) {
|
|
1638
|
+
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
else if (notice.studio.appliedChanges.length > 0) {
|
|
1642
|
+
logger.info('');
|
|
1643
|
+
logger.info(`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1552
1646
|
if (hadFailures) {
|
|
1553
1647
|
for (const failedVideo of failedVideoMessages) {
|
|
1554
1648
|
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
@@ -1770,7 +1864,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1770
1864
|
}
|
|
1771
1865
|
validateArgs(additionalArgs);
|
|
1772
1866
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1773
|
-
|
|
1867
|
+
clearRecordingDirectories(screenciDir);
|
|
1774
1868
|
}
|
|
1775
1869
|
const envForChild = { ...process.env };
|
|
1776
1870
|
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
@@ -1788,7 +1882,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1788
1882
|
logger.info(`Using config: ${configPath}`);
|
|
1789
1883
|
}
|
|
1790
1884
|
const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
|
|
1791
|
-
const spawnSpec =
|
|
1885
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
|
|
1792
1886
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1793
1887
|
stdio: 'inherit',
|
|
1794
1888
|
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
@@ -1843,9 +1937,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1843
1937
|
});
|
|
1844
1938
|
}
|
|
1845
1939
|
function logRecordFailureHint() {
|
|
1940
|
+
logger.info('');
|
|
1846
1941
|
logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
|
|
1847
1942
|
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
|
|
1848
1943
|
}
|
|
1944
|
+
export function logCliError(error) {
|
|
1945
|
+
if (isPartialUploadError(error)) {
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
const errorToLog = isRecordFailureHintError(error) ? error.cause : error;
|
|
1949
|
+
const message = errorToLog instanceof Error ? errorToLog.message : String(errorToLog);
|
|
1950
|
+
logger.error(message);
|
|
1951
|
+
if (isRecordFailureHintError(error)) {
|
|
1952
|
+
logRecordFailureHint();
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1849
1955
|
// Only run if this file is being executed directly
|
|
1850
1956
|
// Check if this module is the main module (handles symlinks properly)
|
|
1851
1957
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1856,10 +1962,7 @@ if (mainFile &&
|
|
|
1856
1962
|
currentRealFile === mainFile ||
|
|
1857
1963
|
currentFile === realpathSync(mainFile))) {
|
|
1858
1964
|
main().catch((error) => {
|
|
1859
|
-
|
|
1860
|
-
process.exit(1);
|
|
1861
|
-
}
|
|
1862
|
-
logger.error('Error:', error.message);
|
|
1965
|
+
logCliError(error);
|
|
1863
1966
|
process.exit(1);
|
|
1864
1967
|
});
|
|
1865
1968
|
}
|