screenci 0.0.61 → 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 +318 -212
- 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 +12 -3
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +248 -54
- 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,12 +70,15 @@ 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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
const pm = determinePackageManager();
|
|
77
|
+
if (pm === 'pnpm')
|
|
78
|
+
return `pnpm exec screenci ${command}`;
|
|
79
|
+
if (pm === 'yarn')
|
|
80
|
+
return `yarn screenci ${command}`;
|
|
81
|
+
return `npx screenci ${command}`;
|
|
60
82
|
}
|
|
61
83
|
async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
62
84
|
const listArgs = [
|
|
@@ -67,7 +89,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
|
67
89
|
'--list',
|
|
68
90
|
'--reporter=json',
|
|
69
91
|
];
|
|
70
|
-
const spawnSpec =
|
|
92
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
|
|
71
93
|
return await new Promise((resolve, reject) => {
|
|
72
94
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
73
95
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
@@ -178,6 +200,28 @@ function contentTypeForPath(filePath) {
|
|
|
178
200
|
};
|
|
179
201
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
180
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
|
+
}
|
|
181
225
|
class UploadAssetError extends Error {
|
|
182
226
|
constructor(message) {
|
|
183
227
|
super(message);
|
|
@@ -196,6 +240,14 @@ class PartialUploadError extends Error {
|
|
|
196
240
|
this.name = 'PartialUploadError';
|
|
197
241
|
}
|
|
198
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
|
+
}
|
|
199
251
|
function isUploadCancelledError(err) {
|
|
200
252
|
return (err instanceof UploadCancelledError ||
|
|
201
253
|
(err instanceof Error &&
|
|
@@ -204,12 +256,12 @@ function isUploadCancelledError(err) {
|
|
|
204
256
|
function isPartialUploadError(err) {
|
|
205
257
|
return err instanceof PartialUploadError;
|
|
206
258
|
}
|
|
259
|
+
function isRecordFailureHintError(err) {
|
|
260
|
+
return err instanceof RecordFailureHintError;
|
|
261
|
+
}
|
|
207
262
|
function isUploadAssetError(err) {
|
|
208
263
|
return err instanceof UploadAssetError;
|
|
209
264
|
}
|
|
210
|
-
function supportsInPlaceUploadUpdates(verbose) {
|
|
211
|
-
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
212
|
-
}
|
|
213
265
|
function formatUploadProgressLine(videoName, status) {
|
|
214
266
|
switch (status) {
|
|
215
267
|
case undefined:
|
|
@@ -226,41 +278,13 @@ function formatUploadProgressLine(videoName, status) {
|
|
|
226
278
|
}
|
|
227
279
|
}
|
|
228
280
|
}
|
|
229
|
-
function createUploadProgressReporter(videoNames,
|
|
230
|
-
const useInPlaceUpdates = supportsInPlaceUploadUpdates(verbose);
|
|
231
|
-
if (!useInPlaceUpdates) {
|
|
232
|
-
return {
|
|
233
|
-
complete(index, status) {
|
|
234
|
-
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
235
|
-
},
|
|
236
|
-
info(message) {
|
|
237
|
-
logger.info(message);
|
|
238
|
-
},
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
const statuses = new Array(videoNames.length);
|
|
242
|
-
let hasRendered = false;
|
|
243
|
-
const render = () => {
|
|
244
|
-
const renderedLines = videoNames.map((videoName, index) => formatUploadProgressLine(videoName, statuses[index]));
|
|
245
|
-
process.stdout.write(`${hasRendered && videoNames.length > 0 ? `\u001B[${videoNames.length}A` : ''}${renderedLines.map((line) => `\r\u001B[2K${line}`).join('\n')}\n`);
|
|
246
|
-
hasRendered = true;
|
|
247
|
-
};
|
|
248
|
-
const clear = () => {
|
|
249
|
-
if (!hasRendered || videoNames.length === 0)
|
|
250
|
-
return;
|
|
251
|
-
process.stdout.write(`\u001B[${videoNames.length}A${Array.from({ length: videoNames.length }, () => '\r\u001B[2K').join('\n')}\n`);
|
|
252
|
-
hasRendered = false;
|
|
253
|
-
};
|
|
254
|
-
render();
|
|
281
|
+
function createUploadProgressReporter(videoNames, _verbose) {
|
|
255
282
|
return {
|
|
256
283
|
complete(index, status) {
|
|
257
|
-
|
|
258
|
-
render();
|
|
284
|
+
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
259
285
|
},
|
|
260
286
|
info(message) {
|
|
261
|
-
clear();
|
|
262
287
|
logger.info(message);
|
|
263
|
-
render();
|
|
264
288
|
},
|
|
265
289
|
};
|
|
266
290
|
}
|
|
@@ -302,9 +326,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
302
326
|
preparedUploadAssets,
|
|
303
327
|
};
|
|
304
328
|
}
|
|
305
|
-
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) {
|
|
306
330
|
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
307
331
|
let projectId = null;
|
|
332
|
+
let videoId = null;
|
|
308
333
|
try {
|
|
309
334
|
uploadAbort.throwIfAborted();
|
|
310
335
|
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
@@ -312,9 +337,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
312
337
|
progressReporter.complete(progressIndex, 'failure');
|
|
313
338
|
return {
|
|
314
339
|
projectId: null,
|
|
340
|
+
videoId: null,
|
|
315
341
|
hadFailure: true,
|
|
316
342
|
videoName,
|
|
317
343
|
failureMessage: `Missing recording.mp4 for "${videoName}"`,
|
|
344
|
+
recordId,
|
|
318
345
|
};
|
|
319
346
|
}
|
|
320
347
|
const recordingHash = await hashFile(recordingPath);
|
|
@@ -323,12 +350,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
323
350
|
headers: {
|
|
324
351
|
'Content-Type': 'application/json',
|
|
325
352
|
'X-ScreenCI-Secret': secret,
|
|
353
|
+
...(elevenLabsApiKey
|
|
354
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
355
|
+
: {}),
|
|
326
356
|
},
|
|
327
357
|
body: JSON.stringify({
|
|
328
358
|
projectName,
|
|
329
359
|
videoName,
|
|
330
360
|
data,
|
|
331
361
|
recordingHash,
|
|
362
|
+
recordId,
|
|
332
363
|
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
333
364
|
fileHash: asset.fileHash,
|
|
334
365
|
size: asset.size,
|
|
@@ -346,14 +377,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
346
377
|
progressReporter.complete(progressIndex, 'failure');
|
|
347
378
|
return {
|
|
348
379
|
projectId: null,
|
|
380
|
+
videoId: null,
|
|
349
381
|
hadFailure: true,
|
|
350
382
|
videoName,
|
|
351
383
|
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
384
|
+
recordId,
|
|
352
385
|
};
|
|
353
386
|
}
|
|
354
387
|
const startBody = (await startResponse.json());
|
|
355
388
|
const { recordingId } = startBody;
|
|
356
389
|
projectId = startBody.projectId;
|
|
390
|
+
videoId = startBody.videoId ?? null;
|
|
391
|
+
const studio = startBody.studio;
|
|
357
392
|
if (verbose) {
|
|
358
393
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
359
394
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -378,6 +413,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
378
413
|
'Content-Type': 'video/mp4',
|
|
379
414
|
'Content-Length': String(fileStat.size),
|
|
380
415
|
'X-ScreenCI-Secret': secret,
|
|
416
|
+
...(elevenLabsApiKey
|
|
417
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
418
|
+
: {}),
|
|
381
419
|
},
|
|
382
420
|
body: stream,
|
|
383
421
|
signal: uploadAbort.signal,
|
|
@@ -389,9 +427,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
389
427
|
progressReporter.complete(progressIndex, 'failure');
|
|
390
428
|
return {
|
|
391
429
|
projectId,
|
|
430
|
+
videoId,
|
|
392
431
|
hadFailure: true,
|
|
393
432
|
videoName,
|
|
394
433
|
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
434
|
+
recordId,
|
|
395
435
|
};
|
|
396
436
|
}
|
|
397
437
|
}
|
|
@@ -400,7 +440,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
400
440
|
}
|
|
401
441
|
progressReporter.complete(progressIndex, 'success');
|
|
402
442
|
cleanupUploadedRecordingDir(screenciDir, entry);
|
|
403
|
-
return {
|
|
443
|
+
return {
|
|
444
|
+
projectId,
|
|
445
|
+
videoId,
|
|
446
|
+
hadFailure: false,
|
|
447
|
+
videoName,
|
|
448
|
+
recordId,
|
|
449
|
+
...(studio !== undefined && { studio }),
|
|
450
|
+
};
|
|
404
451
|
}
|
|
405
452
|
catch (err) {
|
|
406
453
|
if (isUploadCancelledError(err)) {
|
|
@@ -411,17 +458,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
411
458
|
progressReporter.complete(progressIndex, 'failure');
|
|
412
459
|
return {
|
|
413
460
|
projectId,
|
|
461
|
+
videoId,
|
|
414
462
|
hadFailure: true,
|
|
415
463
|
videoName,
|
|
416
464
|
failureMessage: err instanceof Error ? err.message : String(err),
|
|
465
|
+
recordId,
|
|
417
466
|
};
|
|
418
467
|
}
|
|
419
468
|
progressReporter.complete(progressIndex, 'failure');
|
|
420
469
|
return {
|
|
421
470
|
projectId,
|
|
471
|
+
videoId,
|
|
422
472
|
hadFailure: true,
|
|
423
473
|
videoName,
|
|
424
474
|
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
475
|
+
recordId,
|
|
425
476
|
};
|
|
426
477
|
}
|
|
427
478
|
}
|
|
@@ -524,6 +575,15 @@ function resolveWindowsCmdShim(cmd) {
|
|
|
524
575
|
}
|
|
525
576
|
return shimName;
|
|
526
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
|
+
}
|
|
527
587
|
function forwardChildSignals(child, activityLabel, options = {}) {
|
|
528
588
|
let forwardedSignal = null;
|
|
529
589
|
let forceKillTimer = null;
|
|
@@ -591,9 +651,11 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
591
651
|
getForwardedSignal: () => forwardedSignal,
|
|
592
652
|
};
|
|
593
653
|
}
|
|
594
|
-
function
|
|
654
|
+
function clearRecordingDirectories(dir) {
|
|
595
655
|
mkdirSync(dir, { recursive: true });
|
|
596
656
|
for (const entry of readdirSync(dir)) {
|
|
657
|
+
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
658
|
+
continue;
|
|
597
659
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
598
660
|
}
|
|
599
661
|
}
|
|
@@ -689,11 +751,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
689
751
|
}
|
|
690
752
|
return preparedAssets;
|
|
691
753
|
}
|
|
692
|
-
async function collectUploadAssets(data, configDir) {
|
|
754
|
+
export async function collectUploadAssets(data, configDir) {
|
|
693
755
|
const sourceFilePath = data.metadata?.sourceFilePath;
|
|
694
756
|
const assets = new Map();
|
|
695
757
|
for (const event of data.events) {
|
|
696
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;
|
|
697
763
|
if (assets.has(`name:${event.name}`))
|
|
698
764
|
continue;
|
|
699
765
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
@@ -949,6 +1015,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
949
1015
|
}
|
|
950
1016
|
export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
951
1017
|
const uploadAbort = createUploadAbortController('upload');
|
|
1018
|
+
const recordId = randomUUID();
|
|
952
1019
|
let entries;
|
|
953
1020
|
try {
|
|
954
1021
|
entries = await readdir(screenciDir);
|
|
@@ -957,15 +1024,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
957
1024
|
logger.warn('No .screenci directory found, skipping upload');
|
|
958
1025
|
return {
|
|
959
1026
|
projectId: null,
|
|
1027
|
+
recordId: null,
|
|
960
1028
|
hadFailures: false,
|
|
961
1029
|
failedVideoNames: [],
|
|
962
1030
|
failedVideoMessages: [],
|
|
1031
|
+
studioNotices: [],
|
|
963
1032
|
};
|
|
964
1033
|
}
|
|
965
1034
|
if (specificEntry !== undefined) {
|
|
966
1035
|
entries = entries.filter((e) => e === specificEntry);
|
|
967
1036
|
}
|
|
968
1037
|
let firstProjectId = null;
|
|
1038
|
+
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
969
1039
|
try {
|
|
970
1040
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
971
1041
|
uploadAbort.throwIfAborted();
|
|
@@ -974,13 +1044,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
974
1044
|
if (candidates.length === 0) {
|
|
975
1045
|
return {
|
|
976
1046
|
projectId: null,
|
|
1047
|
+
recordId: null,
|
|
977
1048
|
hadFailures: false,
|
|
978
1049
|
failedVideoNames: [],
|
|
979
1050
|
failedVideoMessages: [],
|
|
1051
|
+
studioNotices: [],
|
|
980
1052
|
};
|
|
981
1053
|
}
|
|
982
1054
|
const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
|
|
983
|
-
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)));
|
|
984
1056
|
firstProjectId =
|
|
985
1057
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
986
1058
|
const hadFailures = results.some((result) => result.hadFailure);
|
|
@@ -990,11 +1062,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
990
1062
|
const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
|
|
991
1063
|
? [{ videoName: result.videoName, message: result.failureMessage }]
|
|
992
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
|
+
: []);
|
|
993
1074
|
return {
|
|
994
1075
|
projectId: firstProjectId,
|
|
1076
|
+
recordId,
|
|
995
1077
|
hadFailures,
|
|
996
1078
|
failedVideoNames,
|
|
997
1079
|
failedVideoMessages,
|
|
1080
|
+
studioNotices,
|
|
998
1081
|
};
|
|
999
1082
|
}
|
|
1000
1083
|
finally {
|
|
@@ -1012,16 +1095,38 @@ async function countCompletedRecordings(screenciDir) {
|
|
|
1012
1095
|
return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
|
|
1013
1096
|
}
|
|
1014
1097
|
export function getDevBackendUrl() {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
+
}
|
|
1019
1110
|
}
|
|
1020
1111
|
export function getDevFrontendUrl() {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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`;
|
|
1025
1130
|
}
|
|
1026
1131
|
async function writeGitHubProjectOutput(projectUrl) {
|
|
1027
1132
|
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
@@ -1231,10 +1336,11 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
|
|
|
1231
1336
|
}
|
|
1232
1337
|
async function requireScreenCISecret(configPath) {
|
|
1233
1338
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1234
|
-
const secret = process.env.SCREENCI_SECRET
|
|
1339
|
+
const secret = process.env.SCREENCI_SECRET ??
|
|
1340
|
+
(await ensureScreenciSecret(resolvedConfigPath));
|
|
1235
1341
|
if (!secret) {
|
|
1236
1342
|
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1237
|
-
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)}.`);
|
|
1238
1344
|
process.exit(1);
|
|
1239
1345
|
}
|
|
1240
1346
|
return {
|
|
@@ -1278,29 +1384,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1278
1384
|
}
|
|
1279
1385
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1280
1386
|
}
|
|
1281
|
-
function openBrowser(url) {
|
|
1282
|
-
try {
|
|
1283
|
-
if (process.platform === 'win32') {
|
|
1284
|
-
spawn('cmd', ['/c', 'start', '', url], {
|
|
1285
|
-
detached: true,
|
|
1286
|
-
stdio: 'ignore',
|
|
1287
|
-
shell: true,
|
|
1288
|
-
}).unref();
|
|
1289
|
-
return;
|
|
1290
|
-
}
|
|
1291
|
-
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1292
|
-
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
1293
|
-
}
|
|
1294
|
-
catch (err) {
|
|
1295
|
-
logger.warn('Failed to open browser automatically:', err);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
async function promptToOpenLoginUrlWithSignal(signal) {
|
|
1299
|
-
return await confirm({
|
|
1300
|
-
message: 'Open this link in your browser now?',
|
|
1301
|
-
default: false,
|
|
1302
|
-
}, { signal });
|
|
1303
|
-
}
|
|
1304
1387
|
async function persistScreenCISecret(envFilePath, secret) {
|
|
1305
1388
|
const nextLine = `SCREENCI_SECRET=${secret}`;
|
|
1306
1389
|
try {
|
|
@@ -1327,147 +1410,137 @@ async function persistScreenCISecret(envFilePath, secret) {
|
|
|
1327
1410
|
}
|
|
1328
1411
|
await writeFile(envFilePath, `${nextLine}\n`);
|
|
1329
1412
|
}
|
|
1330
|
-
|
|
1331
|
-
return
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
finish(() => reject(err));
|
|
1363
|
-
}
|
|
1364
|
-
});
|
|
1365
|
-
server.listen(0, '127.0.0.1', () => {
|
|
1366
|
-
const port = server.address().port;
|
|
1367
|
-
const callbackUrl = `http://localhost:${port}/callback`;
|
|
1368
|
-
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
1369
|
-
logger.info(`Open this link to log in to ScreenCI:\n${loginUrl}\n`);
|
|
1370
|
-
void (async () => {
|
|
1371
|
-
if (options?.openBrowserImmediately) {
|
|
1372
|
-
openBrowser(loginUrl);
|
|
1373
|
-
return;
|
|
1374
|
-
}
|
|
1375
|
-
if (settled)
|
|
1376
|
-
return;
|
|
1377
|
-
const shouldOpen = await promptToOpenLoginUrlWithSignal(promptAbort.signal);
|
|
1378
|
-
if (settled)
|
|
1379
|
-
return;
|
|
1380
|
-
if (shouldOpen) {
|
|
1381
|
-
openBrowser(loginUrl);
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
|
|
1385
|
-
})().catch((err) => {
|
|
1386
|
-
if (settled)
|
|
1387
|
-
return;
|
|
1388
|
-
if (err instanceof Error &&
|
|
1389
|
-
(err.name === 'AbortPromptError' ||
|
|
1390
|
-
err.name === 'AbortError' ||
|
|
1391
|
-
err.message === 'Prompt was canceled')) {
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
server.close();
|
|
1395
|
-
finish(() => reject(err));
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
1398
|
-
const timeout = setTimeout(() => {
|
|
1399
|
-
server.close();
|
|
1400
|
-
finish(() => reject(new Error('Authentication timed out after 15 minutes')));
|
|
1401
|
-
}, 15 * 60 * 1000);
|
|
1402
|
-
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',
|
|
1403
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
|
+
}
|
|
1404
1480
|
}
|
|
1405
1481
|
export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
1406
1482
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1407
1483
|
if (existingSecret)
|
|
1408
1484
|
return existingSecret;
|
|
1409
|
-
const appUrl = getDevFrontendUrl();
|
|
1410
1485
|
try {
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
const savePath = resolvedConfigPath
|
|
1486
|
+
const environment = getScreenCIEnvironment();
|
|
1487
|
+
const apiUrl = getCliLinkSessionApiUrl();
|
|
1488
|
+
const appUrl = getDevFrontendUrl();
|
|
1489
|
+
const envFilePath = resolvedConfigPath
|
|
1416
1490
|
? await resolveProjectEnvFilePath(resolvedConfigPath)
|
|
1417
1491
|
: resolve(process.cwd(), '.env');
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
+
}
|
|
1421
1525
|
}
|
|
1422
1526
|
catch (err) {
|
|
1423
1527
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1424
1528
|
logger.warn(`Authentication failed: ${msg}`);
|
|
1425
|
-
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()}.`);
|
|
1426
1530
|
logScreenCISecretGuide();
|
|
1427
1531
|
return undefined;
|
|
1428
1532
|
}
|
|
1429
1533
|
}
|
|
1430
|
-
async function runLogin(configPath, open = false) {
|
|
1431
|
-
const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
|
|
1432
|
-
if (process.env.SCREENCI_SECRET) {
|
|
1433
|
-
logger.info('SCREENCI_SECRET is already configured.');
|
|
1434
|
-
return;
|
|
1435
|
-
}
|
|
1436
|
-
const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1437
|
-
const appUrl = getDevFrontendUrl();
|
|
1438
|
-
try {
|
|
1439
|
-
const secret = await performBrowserLogin(appUrl, {
|
|
1440
|
-
openBrowserImmediately: open,
|
|
1441
|
-
});
|
|
1442
|
-
process.env.SCREENCI_SECRET = secret;
|
|
1443
|
-
await persistScreenCISecret(savePath, secret);
|
|
1444
|
-
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1445
|
-
}
|
|
1446
|
-
catch (err) {
|
|
1447
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1448
|
-
logger.warn(`Authentication failed: ${msg}`);
|
|
1449
|
-
logger.info(`You can run ${pc.cyan(getSuggestedScreenciCommand('login'))} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1450
|
-
logScreenCISecretGuide();
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
1534
|
export async function main() {
|
|
1454
1535
|
if (process.argv.length <= 2) {
|
|
1455
1536
|
logger.error('Error: No command provided');
|
|
1456
|
-
logger.error('Available commands:
|
|
1537
|
+
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1457
1538
|
process.exit(1);
|
|
1458
1539
|
}
|
|
1459
1540
|
const program = new Command();
|
|
1460
1541
|
const defaultPackageManager = determinePackageManager();
|
|
1461
1542
|
program.name('screenci');
|
|
1462
1543
|
program.exitOverride();
|
|
1463
|
-
program
|
|
1464
|
-
.command('login')
|
|
1465
|
-
.description('Authenticate and save SCREENCI_SECRET for this project')
|
|
1466
|
-
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1467
|
-
.option('--open', 'open the login URL in your browser immediately')
|
|
1468
|
-
.action(async (options) => {
|
|
1469
|
-
await runLogin(options.config, options.open === true);
|
|
1470
|
-
});
|
|
1471
1544
|
// record command — playwright args pass through as-is
|
|
1472
1545
|
program
|
|
1473
1546
|
.command('record [playwrightArgs...]')
|
|
@@ -1481,13 +1554,13 @@ export async function main() {
|
|
|
1481
1554
|
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1482
1555
|
}
|
|
1483
1556
|
catch (error) {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
playwrightFailure = error;
|
|
1557
|
+
if (!(error instanceof Error))
|
|
1558
|
+
throw error;
|
|
1559
|
+
if (error.message.startsWith('Playwright exited with code ')) {
|
|
1560
|
+
playwrightFailure = new RecordFailureHintError(error);
|
|
1488
1561
|
}
|
|
1489
1562
|
else {
|
|
1490
|
-
throw error;
|
|
1563
|
+
throw new RecordFailureHintError(error);
|
|
1491
1564
|
}
|
|
1492
1565
|
}
|
|
1493
1566
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
@@ -1511,7 +1584,7 @@ export async function main() {
|
|
|
1511
1584
|
logger.info('All recordings failed.');
|
|
1512
1585
|
}
|
|
1513
1586
|
else if (!secret) {
|
|
1514
|
-
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.`);
|
|
1515
1588
|
}
|
|
1516
1589
|
else if (playwrightFailure !== null &&
|
|
1517
1590
|
uploadPolicy === 'all-or-nothing') {
|
|
@@ -1523,9 +1596,11 @@ export async function main() {
|
|
|
1523
1596
|
}
|
|
1524
1597
|
let uploadResult = {
|
|
1525
1598
|
projectId: null,
|
|
1599
|
+
recordId: null,
|
|
1526
1600
|
hadFailures: false,
|
|
1527
1601
|
failedVideoNames: [],
|
|
1528
1602
|
failedVideoMessages: [],
|
|
1603
|
+
studioNotices: [],
|
|
1529
1604
|
};
|
|
1530
1605
|
try {
|
|
1531
1606
|
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
@@ -1536,8 +1611,17 @@ export async function main() {
|
|
|
1536
1611
|
}
|
|
1537
1612
|
throw err;
|
|
1538
1613
|
}
|
|
1539
|
-
const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
|
|
1540
|
-
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) {
|
|
1541
1625
|
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1542
1626
|
await writeGitHubProjectOutput(projectUrl);
|
|
1543
1627
|
logger.info('');
|
|
@@ -1546,6 +1630,19 @@ export async function main() {
|
|
|
1546
1630
|
: 'Recording finished, rendering in progress. Results available at:');
|
|
1547
1631
|
logger.info(pc.cyan(projectUrl));
|
|
1548
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
|
+
}
|
|
1549
1646
|
if (hadFailures) {
|
|
1550
1647
|
for (const failedVideo of failedVideoMessages) {
|
|
1551
1648
|
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
@@ -1623,7 +1720,7 @@ export async function main() {
|
|
|
1623
1720
|
.command('init [name]')
|
|
1624
1721
|
.description('Initialize a new screenci project')
|
|
1625
1722
|
.option('--agent <name>', 'target agent for skills install, e.g. opencode. Supported agents: https://github.com/vercel-labs/skills#supported-agents')
|
|
1626
|
-
.option('--package-manager <manager>', `package manager to use: npm or
|
|
1723
|
+
.option('--package-manager <manager>', `package manager to use: npm, pnpm, or yarn 2+ (default: ${defaultPackageManager})`)
|
|
1627
1724
|
.option('-y, --yes', 'accept init defaults')
|
|
1628
1725
|
.option('-v, --verbose', 'verbose output')
|
|
1629
1726
|
.action(async (name, options) => {
|
|
@@ -1631,7 +1728,7 @@ export async function main() {
|
|
|
1631
1728
|
await runInit(name, {
|
|
1632
1729
|
verbose: options['verbose'] ?? false,
|
|
1633
1730
|
yes: options['yes'] ?? false,
|
|
1634
|
-
packageManager: parsePackageManager(options['packageManager']),
|
|
1731
|
+
packageManager: parsePackageManager(options['packageManager'], process.env['SCREENCI_INIT_CWD'] ?? process.cwd()),
|
|
1635
1732
|
...(agent !== undefined ? { agent } : {}),
|
|
1636
1733
|
});
|
|
1637
1734
|
});
|
|
@@ -1767,7 +1864,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1767
1864
|
}
|
|
1768
1865
|
validateArgs(additionalArgs);
|
|
1769
1866
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1770
|
-
|
|
1867
|
+
clearRecordingDirectories(screenciDir);
|
|
1771
1868
|
}
|
|
1772
1869
|
const envForChild = { ...process.env };
|
|
1773
1870
|
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
@@ -1785,7 +1882,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1785
1882
|
logger.info(`Using config: ${configPath}`);
|
|
1786
1883
|
}
|
|
1787
1884
|
const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
|
|
1788
|
-
const spawnSpec =
|
|
1885
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
|
|
1789
1886
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1790
1887
|
stdio: 'inherit',
|
|
1791
1888
|
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
@@ -1840,9 +1937,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1840
1937
|
});
|
|
1841
1938
|
}
|
|
1842
1939
|
function logRecordFailureHint() {
|
|
1940
|
+
logger.info('');
|
|
1843
1941
|
logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
|
|
1844
1942
|
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
|
|
1845
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
|
+
}
|
|
1846
1955
|
// Only run if this file is being executed directly
|
|
1847
1956
|
// Check if this module is the main module (handles symlinks properly)
|
|
1848
1957
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1853,10 +1962,7 @@ if (mainFile &&
|
|
|
1853
1962
|
currentRealFile === mainFile ||
|
|
1854
1963
|
currentFile === realpathSync(mainFile))) {
|
|
1855
1964
|
main().catch((error) => {
|
|
1856
|
-
|
|
1857
|
-
process.exit(1);
|
|
1858
|
-
}
|
|
1859
|
-
logger.error('Error:', error.message);
|
|
1965
|
+
logCliError(error);
|
|
1860
1966
|
process.exit(1);
|
|
1861
1967
|
});
|
|
1862
1968
|
}
|