screenci 0.0.62 → 0.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -15
- package/bin/screenci.js +2 -2
- package/dist/cli.d.ts +46 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +463 -242
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +132 -70
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +46 -24
- package/dist/docs/manifest.js.map +1 -1
- package/dist/docs/videos.d.ts +1 -1
- package/dist/docs/videos.js +1 -1
- package/dist/docs/videos.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 +9 -0
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +293 -113
- 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 +7 -8
- package/skills/screenci/references/init.md +1 -2
- package/skills/screenci/references/record.md +3 -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,44 @@ 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
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reports whether the current session can complete an interactive browser
|
|
41
|
+
* sign-in. A session is interactive only when both stdin and stdout are
|
|
42
|
+
* attached to a terminal and no signal marks the run as automated. This is the
|
|
43
|
+
* proxy for "a human is present to open the sign-in link" — it does not attempt
|
|
44
|
+
* to identify any particular caller (CI, a piped shell, or an automated tool).
|
|
45
|
+
*
|
|
46
|
+
* Dependency-injected so tests can force a value without a real terminal.
|
|
47
|
+
*/
|
|
48
|
+
export function detectInteractiveSession(env = process.env, stdout = process.stdout, stdin = process.stdin) {
|
|
49
|
+
if (env.SCREENCI_NONINTERACTIVE === '1')
|
|
50
|
+
return false;
|
|
51
|
+
if (env.CI === 'true')
|
|
52
|
+
return false;
|
|
53
|
+
return Boolean(stdout.isTTY) && Boolean(stdin.isTTY);
|
|
54
|
+
}
|
|
20
55
|
export function collectPlaywrightListTitles(suites) {
|
|
21
56
|
const titles = [];
|
|
22
57
|
const visitSuite = (suite) => {
|
|
@@ -51,7 +86,7 @@ function extractPlaywrightDiscoveryError(output) {
|
|
|
51
86
|
}
|
|
52
87
|
}
|
|
53
88
|
function logScreenCISecretGuide() {
|
|
54
|
-
logger.info(`Guide: ${pc.cyan(
|
|
89
|
+
logger.info(`Guide: ${pc.cyan(SCREENCI_RECORD_DOCS_URL)}`);
|
|
55
90
|
}
|
|
56
91
|
function getSuggestedScreenciCommand(command) {
|
|
57
92
|
const pm = determinePackageManager();
|
|
@@ -70,7 +105,7 @@ async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
|
70
105
|
'--list',
|
|
71
106
|
'--reporter=json',
|
|
72
107
|
];
|
|
73
|
-
const spawnSpec =
|
|
108
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(listArgs, dirname(configPath));
|
|
74
109
|
return await new Promise((resolve, reject) => {
|
|
75
110
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
76
111
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
@@ -181,6 +216,28 @@ function contentTypeForPath(filePath) {
|
|
|
181
216
|
};
|
|
182
217
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
183
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* One-line summary of Studio overrides for CLI output, e.g.
|
|
221
|
+
* `recording.size (1 → 0.8), narration "intro" (en)`.
|
|
222
|
+
*/
|
|
223
|
+
export function formatStudioChangeSummary(changes) {
|
|
224
|
+
return changes
|
|
225
|
+
.map((change) => {
|
|
226
|
+
const label = change.label ?? 'selection';
|
|
227
|
+
if (change.kind === 'narration') {
|
|
228
|
+
return change.language !== undefined
|
|
229
|
+
? `${label} (${change.language})`
|
|
230
|
+
: label;
|
|
231
|
+
}
|
|
232
|
+
return change.from !== undefined && change.to !== undefined
|
|
233
|
+
? `${label} (${change.from} → ${change.to})`
|
|
234
|
+
: label;
|
|
235
|
+
})
|
|
236
|
+
.join(', ');
|
|
237
|
+
}
|
|
238
|
+
export function formatStudioUrl(appUrl, projectId, videoId) {
|
|
239
|
+
return `${appUrl}/project/${projectId}/video/${videoId}/studio`;
|
|
240
|
+
}
|
|
184
241
|
class UploadAssetError extends Error {
|
|
185
242
|
constructor(message) {
|
|
186
243
|
super(message);
|
|
@@ -199,6 +256,14 @@ class PartialUploadError extends Error {
|
|
|
199
256
|
this.name = 'PartialUploadError';
|
|
200
257
|
}
|
|
201
258
|
}
|
|
259
|
+
class RecordFailureHintError extends Error {
|
|
260
|
+
cause;
|
|
261
|
+
constructor(cause) {
|
|
262
|
+
super(cause.message);
|
|
263
|
+
this.name = cause.name;
|
|
264
|
+
this.cause = cause;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
202
267
|
function isUploadCancelledError(err) {
|
|
203
268
|
return (err instanceof UploadCancelledError ||
|
|
204
269
|
(err instanceof Error &&
|
|
@@ -207,12 +272,12 @@ function isUploadCancelledError(err) {
|
|
|
207
272
|
function isPartialUploadError(err) {
|
|
208
273
|
return err instanceof PartialUploadError;
|
|
209
274
|
}
|
|
275
|
+
function isRecordFailureHintError(err) {
|
|
276
|
+
return err instanceof RecordFailureHintError;
|
|
277
|
+
}
|
|
210
278
|
function isUploadAssetError(err) {
|
|
211
279
|
return err instanceof UploadAssetError;
|
|
212
280
|
}
|
|
213
|
-
function supportsInPlaceUploadUpdates(verbose) {
|
|
214
|
-
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
215
|
-
}
|
|
216
281
|
function formatUploadProgressLine(videoName, status) {
|
|
217
282
|
switch (status) {
|
|
218
283
|
case undefined:
|
|
@@ -229,41 +294,13 @@ function formatUploadProgressLine(videoName, status) {
|
|
|
229
294
|
}
|
|
230
295
|
}
|
|
231
296
|
}
|
|
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();
|
|
297
|
+
function createUploadProgressReporter(videoNames, _verbose) {
|
|
258
298
|
return {
|
|
259
299
|
complete(index, status) {
|
|
260
|
-
|
|
261
|
-
render();
|
|
300
|
+
logger.info(formatUploadProgressLine(videoNames[index] ?? 'unknown', status));
|
|
262
301
|
},
|
|
263
302
|
info(message) {
|
|
264
|
-
clear();
|
|
265
303
|
logger.info(message);
|
|
266
|
-
render();
|
|
267
304
|
},
|
|
268
305
|
};
|
|
269
306
|
}
|
|
@@ -305,9 +342,10 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
305
342
|
preparedUploadAssets,
|
|
306
343
|
};
|
|
307
344
|
}
|
|
308
|
-
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
|
|
345
|
+
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, progressIndex, recordId) {
|
|
309
346
|
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
310
347
|
let projectId = null;
|
|
348
|
+
let videoId = null;
|
|
311
349
|
try {
|
|
312
350
|
uploadAbort.throwIfAborted();
|
|
313
351
|
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
@@ -315,9 +353,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
315
353
|
progressReporter.complete(progressIndex, 'failure');
|
|
316
354
|
return {
|
|
317
355
|
projectId: null,
|
|
356
|
+
videoId: null,
|
|
318
357
|
hadFailure: true,
|
|
319
358
|
videoName,
|
|
320
359
|
failureMessage: `Missing recording.mp4 for "${videoName}"`,
|
|
360
|
+
recordId,
|
|
321
361
|
};
|
|
322
362
|
}
|
|
323
363
|
const recordingHash = await hashFile(recordingPath);
|
|
@@ -326,12 +366,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
326
366
|
headers: {
|
|
327
367
|
'Content-Type': 'application/json',
|
|
328
368
|
'X-ScreenCI-Secret': secret,
|
|
369
|
+
...(elevenLabsApiKey
|
|
370
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
371
|
+
: {}),
|
|
329
372
|
},
|
|
330
373
|
body: JSON.stringify({
|
|
331
374
|
projectName,
|
|
332
375
|
videoName,
|
|
333
376
|
data,
|
|
334
377
|
recordingHash,
|
|
378
|
+
recordId,
|
|
335
379
|
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
336
380
|
fileHash: asset.fileHash,
|
|
337
381
|
size: asset.size,
|
|
@@ -349,14 +393,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
349
393
|
progressReporter.complete(progressIndex, 'failure');
|
|
350
394
|
return {
|
|
351
395
|
projectId: null,
|
|
396
|
+
videoId: null,
|
|
352
397
|
hadFailure: true,
|
|
353
398
|
videoName,
|
|
354
399
|
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
400
|
+
recordId,
|
|
355
401
|
};
|
|
356
402
|
}
|
|
357
403
|
const startBody = (await startResponse.json());
|
|
358
404
|
const { recordingId } = startBody;
|
|
359
405
|
projectId = startBody.projectId;
|
|
406
|
+
videoId = startBody.videoId ?? null;
|
|
407
|
+
const studio = startBody.studio;
|
|
360
408
|
if (verbose) {
|
|
361
409
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
362
410
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -381,6 +429,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
381
429
|
'Content-Type': 'video/mp4',
|
|
382
430
|
'Content-Length': String(fileStat.size),
|
|
383
431
|
'X-ScreenCI-Secret': secret,
|
|
432
|
+
...(elevenLabsApiKey
|
|
433
|
+
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
434
|
+
: {}),
|
|
384
435
|
},
|
|
385
436
|
body: stream,
|
|
386
437
|
signal: uploadAbort.signal,
|
|
@@ -392,9 +443,11 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
392
443
|
progressReporter.complete(progressIndex, 'failure');
|
|
393
444
|
return {
|
|
394
445
|
projectId,
|
|
446
|
+
videoId,
|
|
395
447
|
hadFailure: true,
|
|
396
448
|
videoName,
|
|
397
449
|
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
450
|
+
recordId,
|
|
398
451
|
};
|
|
399
452
|
}
|
|
400
453
|
}
|
|
@@ -403,7 +456,14 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
403
456
|
}
|
|
404
457
|
progressReporter.complete(progressIndex, 'success');
|
|
405
458
|
cleanupUploadedRecordingDir(screenciDir, entry);
|
|
406
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
projectId,
|
|
461
|
+
videoId,
|
|
462
|
+
hadFailure: false,
|
|
463
|
+
videoName,
|
|
464
|
+
recordId,
|
|
465
|
+
...(studio !== undefined && { studio }),
|
|
466
|
+
};
|
|
407
467
|
}
|
|
408
468
|
catch (err) {
|
|
409
469
|
if (isUploadCancelledError(err)) {
|
|
@@ -414,17 +474,21 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
414
474
|
progressReporter.complete(progressIndex, 'failure');
|
|
415
475
|
return {
|
|
416
476
|
projectId,
|
|
477
|
+
videoId,
|
|
417
478
|
hadFailure: true,
|
|
418
479
|
videoName,
|
|
419
480
|
failureMessage: err instanceof Error ? err.message : String(err),
|
|
481
|
+
recordId,
|
|
420
482
|
};
|
|
421
483
|
}
|
|
422
484
|
progressReporter.complete(progressIndex, 'failure');
|
|
423
485
|
return {
|
|
424
486
|
projectId,
|
|
487
|
+
videoId,
|
|
425
488
|
hadFailure: true,
|
|
426
489
|
videoName,
|
|
427
490
|
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
491
|
+
recordId,
|
|
428
492
|
};
|
|
429
493
|
}
|
|
430
494
|
}
|
|
@@ -527,6 +591,32 @@ function resolveWindowsCmdShim(cmd) {
|
|
|
527
591
|
}
|
|
528
592
|
return shimName;
|
|
529
593
|
}
|
|
594
|
+
function isModuleNotFoundError(error) {
|
|
595
|
+
return (error instanceof Error &&
|
|
596
|
+
error.code === 'MODULE_NOT_FOUND');
|
|
597
|
+
}
|
|
598
|
+
function resolvePlaywrightCliEntrypoint(searchFrom) {
|
|
599
|
+
// Prefer the @playwright/test installed alongside the user's config, since it
|
|
600
|
+
// is declared as a peer dependency of the project being recorded.
|
|
601
|
+
try {
|
|
602
|
+
return require.resolve('@playwright/test/cli', { paths: [searchFrom] });
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (!isModuleNotFoundError(error))
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
// Fall back to the copy resolvable from the screenci CLI's own install. This
|
|
609
|
+
// keeps discovery working when Playwright is hoisted to a parent install or
|
|
610
|
+
// bundled with the CLI rather than next to the config file.
|
|
611
|
+
return require.resolve('@playwright/test/cli');
|
|
612
|
+
}
|
|
613
|
+
function resolvePlaywrightSpawnSpec(args, searchFrom) {
|
|
614
|
+
const cliEntrypoint = resolvePlaywrightCliEntrypoint(searchFrom);
|
|
615
|
+
return {
|
|
616
|
+
command: process.execPath,
|
|
617
|
+
args: [cliEntrypoint, ...args],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
530
620
|
function forwardChildSignals(child, activityLabel, options = {}) {
|
|
531
621
|
let forwardedSignal = null;
|
|
532
622
|
let forceKillTimer = null;
|
|
@@ -594,26 +684,72 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
594
684
|
getForwardedSignal: () => forwardedSignal,
|
|
595
685
|
};
|
|
596
686
|
}
|
|
597
|
-
function
|
|
687
|
+
function clearRecordingDirectories(dir) {
|
|
598
688
|
mkdirSync(dir, { recursive: true });
|
|
599
689
|
for (const entry of readdirSync(dir)) {
|
|
690
|
+
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
691
|
+
continue;
|
|
600
692
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
601
693
|
}
|
|
602
694
|
}
|
|
603
695
|
function findScreenCIConfig(customPath) {
|
|
604
696
|
if (customPath) {
|
|
605
697
|
const resolvedPath = resolve(process.cwd(), customPath);
|
|
606
|
-
|
|
607
|
-
|
|
698
|
+
return existsSync(resolvedPath)
|
|
699
|
+
? { kind: 'found', path: resolvedPath }
|
|
700
|
+
: { kind: 'not-found' };
|
|
701
|
+
}
|
|
702
|
+
// Walk up from the current directory looking for a flat `screenci.config.ts`,
|
|
703
|
+
// which is what's present when the command runs from inside the `screenci/`
|
|
704
|
+
// island. We deliberately do NOT auto-use a nested
|
|
705
|
+
// `screenci/screenci.config.ts`: running the CLI from outside the island
|
|
706
|
+
// resolves the `screenci` binary from the registry (npx download) rather than
|
|
707
|
+
// the version-pinned island install, so it would silently run a different
|
|
708
|
+
// version. Instead we detect the island and ask the user to `cd` into it.
|
|
709
|
+
let current = process.cwd();
|
|
710
|
+
let islandConfigPath;
|
|
711
|
+
while (true) {
|
|
712
|
+
const flatConfig = resolve(current, 'screenci.config.ts');
|
|
713
|
+
if (existsSync(flatConfig)) {
|
|
714
|
+
return { kind: 'found', path: flatConfig };
|
|
715
|
+
}
|
|
716
|
+
if (islandConfigPath === undefined) {
|
|
717
|
+
const islandConfig = resolve(current, 'screenci', 'screenci.config.ts');
|
|
718
|
+
if (existsSync(islandConfig)) {
|
|
719
|
+
islandConfigPath = islandConfig;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const parent = dirname(current);
|
|
723
|
+
if (parent === current)
|
|
724
|
+
break;
|
|
725
|
+
current = parent;
|
|
726
|
+
}
|
|
727
|
+
if (islandConfigPath !== undefined) {
|
|
728
|
+
return { kind: 'island-not-entered', islandConfigPath };
|
|
729
|
+
}
|
|
730
|
+
return { kind: 'not-found' };
|
|
731
|
+
}
|
|
732
|
+
// Resolve the config path, or log a helpful message and exit. Centralizes the
|
|
733
|
+
// `cd screenci` guidance so every command (test/record/info/...) behaves the
|
|
734
|
+
// same when invoked from outside the island.
|
|
735
|
+
function resolveScreenCIConfigPathOrExit(customPath) {
|
|
736
|
+
const resolution = findScreenCIConfig(customPath);
|
|
737
|
+
switch (resolution.kind) {
|
|
738
|
+
case 'found':
|
|
739
|
+
return resolution.path;
|
|
740
|
+
case 'island-not-entered': {
|
|
741
|
+
const islandDir = dirname(resolution.islandConfigPath);
|
|
742
|
+
const relDir = pathRelative(process.cwd(), islandDir) || '.';
|
|
743
|
+
logger.error(`Error: no screenci.config.ts found here, but found ${pc.cyan(`${relDir}/screenci.config.ts`)}. Run ${pc.cyan(`cd ${relDir}`)} and rerun the command from there.`);
|
|
744
|
+
return process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
case 'not-found': {
|
|
747
|
+
logger.error(customPath
|
|
748
|
+
? `Error: Config file not found: ${customPath}`
|
|
749
|
+
: 'Error: screenci.config.ts not found in the current directory or any parent.');
|
|
750
|
+
return process.exit(1);
|
|
608
751
|
}
|
|
609
|
-
return null;
|
|
610
|
-
}
|
|
611
|
-
const cwd = process.cwd();
|
|
612
|
-
const configPath = resolve(cwd, 'screenci.config.ts');
|
|
613
|
-
if (existsSync(configPath)) {
|
|
614
|
-
return configPath;
|
|
615
752
|
}
|
|
616
|
-
return null;
|
|
617
753
|
}
|
|
618
754
|
async function hashFile(filePath) {
|
|
619
755
|
return new Promise((resolveHash, reject) => {
|
|
@@ -692,11 +828,15 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
692
828
|
}
|
|
693
829
|
return preparedAssets;
|
|
694
830
|
}
|
|
695
|
-
async function collectUploadAssets(data, configDir) {
|
|
831
|
+
export async function collectUploadAssets(data, configDir) {
|
|
696
832
|
const sourceFilePath = data.metadata?.sourceFilePath;
|
|
697
833
|
const assets = new Map();
|
|
698
834
|
for (const event of data.events) {
|
|
699
835
|
if (event.type === 'assetStart') {
|
|
836
|
+
// Studio assets have no local file — they are uploaded from the Studio
|
|
837
|
+
// page and merged into the recording by the backend.
|
|
838
|
+
if ('studio' in event && event.studio === true)
|
|
839
|
+
continue;
|
|
700
840
|
if (assets.has(`name:${event.name}`))
|
|
701
841
|
continue;
|
|
702
842
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
@@ -952,6 +1092,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
952
1092
|
}
|
|
953
1093
|
export async function uploadRecordings(screenciDir, projectName, apiUrl, secret, specificEntry, verbose = false) {
|
|
954
1094
|
const uploadAbort = createUploadAbortController('upload');
|
|
1095
|
+
const recordId = randomUUID();
|
|
955
1096
|
let entries;
|
|
956
1097
|
try {
|
|
957
1098
|
entries = await readdir(screenciDir);
|
|
@@ -960,15 +1101,18 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
960
1101
|
logger.warn('No .screenci directory found, skipping upload');
|
|
961
1102
|
return {
|
|
962
1103
|
projectId: null,
|
|
1104
|
+
recordId: null,
|
|
963
1105
|
hadFailures: false,
|
|
964
1106
|
failedVideoNames: [],
|
|
965
1107
|
failedVideoMessages: [],
|
|
1108
|
+
studioNotices: [],
|
|
966
1109
|
};
|
|
967
1110
|
}
|
|
968
1111
|
if (specificEntry !== undefined) {
|
|
969
1112
|
entries = entries.filter((e) => e === specificEntry);
|
|
970
1113
|
}
|
|
971
1114
|
let firstProjectId = null;
|
|
1115
|
+
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
972
1116
|
try {
|
|
973
1117
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
974
1118
|
uploadAbort.throwIfAborted();
|
|
@@ -977,13 +1121,15 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
977
1121
|
if (candidates.length === 0) {
|
|
978
1122
|
return {
|
|
979
1123
|
projectId: null,
|
|
1124
|
+
recordId: null,
|
|
980
1125
|
hadFailures: false,
|
|
981
1126
|
failedVideoNames: [],
|
|
982
1127
|
failedVideoMessages: [],
|
|
1128
|
+
studioNotices: [],
|
|
983
1129
|
};
|
|
984
1130
|
}
|
|
985
1131
|
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)));
|
|
1132
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId)));
|
|
987
1133
|
firstProjectId =
|
|
988
1134
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
989
1135
|
const hadFailures = results.some((result) => result.hadFailure);
|
|
@@ -993,11 +1139,22 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
993
1139
|
const failedVideoMessages = results.flatMap((result) => result.hadFailure && typeof result.failureMessage === 'string'
|
|
994
1140
|
? [{ videoName: result.videoName, message: result.failureMessage }]
|
|
995
1141
|
: []);
|
|
1142
|
+
const studioNotices = results.flatMap((result) => !result.hadFailure && result.studio !== undefined
|
|
1143
|
+
? [
|
|
1144
|
+
{
|
|
1145
|
+
videoName: result.videoName,
|
|
1146
|
+
videoId: result.videoId,
|
|
1147
|
+
studio: result.studio,
|
|
1148
|
+
},
|
|
1149
|
+
]
|
|
1150
|
+
: []);
|
|
996
1151
|
return {
|
|
997
1152
|
projectId: firstProjectId,
|
|
1153
|
+
recordId,
|
|
998
1154
|
hadFailures,
|
|
999
1155
|
failedVideoNames,
|
|
1000
1156
|
failedVideoMessages,
|
|
1157
|
+
studioNotices,
|
|
1001
1158
|
};
|
|
1002
1159
|
}
|
|
1003
1160
|
finally {
|
|
@@ -1015,16 +1172,38 @@ async function countCompletedRecordings(screenciDir) {
|
|
|
1015
1172
|
return entries.filter((entry) => existsSync(resolve(screenciDir, entry, 'data.json'))).length;
|
|
1016
1173
|
}
|
|
1017
1174
|
export function getDevBackendUrl() {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1175
|
+
switch (getScreenCIEnvironment()) {
|
|
1176
|
+
case 'local': {
|
|
1177
|
+
const devBackendPort = process.env.DEV_BACKEND_PORT;
|
|
1178
|
+
return devBackendPort
|
|
1179
|
+
? `http://localhost:${devBackendPort}`
|
|
1180
|
+
: 'http://localhost:8787';
|
|
1181
|
+
}
|
|
1182
|
+
case 'dev':
|
|
1183
|
+
return SCREENCI_DEVELOPMENT_BACKEND_URL;
|
|
1184
|
+
case 'prod':
|
|
1185
|
+
return SCREENCI_PRODUCTION_BACKEND_URL;
|
|
1186
|
+
}
|
|
1022
1187
|
}
|
|
1023
1188
|
export function getDevFrontendUrl() {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1189
|
+
switch (getScreenCIEnvironment()) {
|
|
1190
|
+
case 'local': {
|
|
1191
|
+
const devFrontendPort = process.env.DEV_FRONTEND_PORT;
|
|
1192
|
+
return devFrontendPort
|
|
1193
|
+
? `http://localhost:${devFrontendPort}`
|
|
1194
|
+
: 'http://localhost:5173';
|
|
1195
|
+
}
|
|
1196
|
+
case 'dev':
|
|
1197
|
+
return SCREENCI_DEVELOPMENT_FRONTEND_URL;
|
|
1198
|
+
case 'prod':
|
|
1199
|
+
return SCREENCI_PRODUCTION_FRONTEND_URL;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
export function getCliLinkSessionApiUrl() {
|
|
1203
|
+
return getDevFrontendUrl();
|
|
1204
|
+
}
|
|
1205
|
+
function getScreenCISecretsUrl() {
|
|
1206
|
+
return `${getDevFrontendUrl()}/secrets`;
|
|
1028
1207
|
}
|
|
1029
1208
|
async function writeGitHubProjectOutput(projectUrl) {
|
|
1030
1209
|
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
@@ -1033,14 +1212,7 @@ async function writeGitHubProjectOutput(projectUrl) {
|
|
|
1033
1212
|
await appendFile(githubOutput, `screenci_project_url=${projectUrl}\n`);
|
|
1034
1213
|
}
|
|
1035
1214
|
async function loadScreenCIConfigAndEnv(configPath) {
|
|
1036
|
-
const resolvedConfigPath =
|
|
1037
|
-
if (!resolvedConfigPath) {
|
|
1038
|
-
const errorMsg = configPath
|
|
1039
|
-
? `Error: Config file not found: ${configPath}`
|
|
1040
|
-
: 'Error: screenci.config.ts not found in current directory';
|
|
1041
|
-
logger.error(errorMsg);
|
|
1042
|
-
process.exit(1);
|
|
1043
|
-
}
|
|
1215
|
+
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath);
|
|
1044
1216
|
let screenciConfig;
|
|
1045
1217
|
try {
|
|
1046
1218
|
screenciConfig =
|
|
@@ -1232,12 +1404,18 @@ async function loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath) {
|
|
|
1232
1404
|
throw err;
|
|
1233
1405
|
}
|
|
1234
1406
|
}
|
|
1235
|
-
async function requireScreenCISecret(configPath) {
|
|
1407
|
+
async function requireScreenCISecret(configPath, opts = {}) {
|
|
1236
1408
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1237
|
-
const secret = process.env.SCREENCI_SECRET
|
|
1409
|
+
const secret = process.env.SCREENCI_SECRET ??
|
|
1410
|
+
(await ensureScreenciSecret(resolvedConfigPath, opts));
|
|
1238
1411
|
if (!secret) {
|
|
1412
|
+
// In a non-interactive session ensureScreenciSecret already printed the
|
|
1413
|
+
// sign-in link and the next step, so we exit without repeating guidance.
|
|
1414
|
+
if (opts.interactive === false) {
|
|
1415
|
+
process.exit(1);
|
|
1416
|
+
}
|
|
1239
1417
|
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1240
|
-
logger.error(`No SCREENCI_SECRET configured.
|
|
1418
|
+
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
1419
|
process.exit(1);
|
|
1242
1420
|
}
|
|
1243
1421
|
return {
|
|
@@ -1248,7 +1426,7 @@ async function requireScreenCISecret(configPath) {
|
|
|
1248
1426
|
};
|
|
1249
1427
|
}
|
|
1250
1428
|
async function fetchProjectInfo(configPath) {
|
|
1251
|
-
const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath);
|
|
1429
|
+
const { screenciConfig, secret, apiUrl } = await requireScreenCISecret(configPath, { interactive: detectInteractiveSession() });
|
|
1252
1430
|
const url = new URL(`${apiUrl}/cli/project-info`);
|
|
1253
1431
|
url.searchParams.set('projectName', screenciConfig.projectName);
|
|
1254
1432
|
const res = await fetch(url.toString(), {
|
|
@@ -1267,7 +1445,9 @@ async function printProjectInfo(configPath) {
|
|
|
1267
1445
|
process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
1268
1446
|
}
|
|
1269
1447
|
async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
1270
|
-
const { secret, apiUrl } = await requireScreenCISecret(configPath
|
|
1448
|
+
const { secret, apiUrl } = await requireScreenCISecret(configPath, {
|
|
1449
|
+
interactive: detectInteractiveSession(),
|
|
1450
|
+
});
|
|
1271
1451
|
const method = isPublic ? 'PUT' : 'DELETE';
|
|
1272
1452
|
const res = await fetch(`${apiUrl}/cli/public-video/${videoId}`, {
|
|
1273
1453
|
method,
|
|
@@ -1281,29 +1461,6 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1281
1461
|
}
|
|
1282
1462
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1283
1463
|
}
|
|
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
1464
|
async function persistScreenCISecret(envFilePath, secret) {
|
|
1308
1465
|
const nextLine = `SCREENCI_SECRET=${secret}`;
|
|
1309
1466
|
try {
|
|
@@ -1330,147 +1487,177 @@ async function persistScreenCISecret(envFilePath, secret) {
|
|
|
1330
1487
|
}
|
|
1331
1488
|
await writeFile(envFilePath, `${nextLine}\n`);
|
|
1332
1489
|
}
|
|
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));
|
|
1490
|
+
function getLinkSessionFilePath(projectDir) {
|
|
1491
|
+
return resolve(projectDir, '.screenci', SCREENCI_LINK_SESSION_FILE);
|
|
1492
|
+
}
|
|
1493
|
+
async function readPersistedLinkSessionSpec(specPath) {
|
|
1494
|
+
try {
|
|
1495
|
+
const raw = await readFile(specPath, 'utf-8');
|
|
1496
|
+
return JSON.parse(raw);
|
|
1497
|
+
}
|
|
1498
|
+
catch (error) {
|
|
1499
|
+
if (!isMissingFileError(error)) {
|
|
1500
|
+
logger.warn(`Ignoring invalid stored link session at ${specPath}.`);
|
|
1501
|
+
rmSync(specPath, { force: true });
|
|
1502
|
+
}
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
async function writePersistedLinkSessionSpec(specPath, spec) {
|
|
1507
|
+
mkdirSync(dirname(specPath), { recursive: true });
|
|
1508
|
+
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
1509
|
+
}
|
|
1510
|
+
function deletePersistedLinkSessionSpec(specPath) {
|
|
1511
|
+
rmSync(specPath, { force: true });
|
|
1512
|
+
}
|
|
1513
|
+
function isStoredLinkSessionReusable(spec, options) {
|
|
1514
|
+
return (spec.environment === options.environment &&
|
|
1515
|
+
spec.envFilePath === options.envFilePath &&
|
|
1516
|
+
spec.resolvedConfigPath === options.resolvedConfigPath &&
|
|
1517
|
+
spec.expiresAt > new Date().toISOString());
|
|
1518
|
+
}
|
|
1519
|
+
async function createLinkSessionSpec(options) {
|
|
1520
|
+
const response = await fetch(`${options.apiUrl}/cli-link/session`, {
|
|
1521
|
+
method: 'POST',
|
|
1406
1522
|
});
|
|
1523
|
+
if (!response.ok) {
|
|
1524
|
+
const text = await response.text();
|
|
1525
|
+
throw new Error(`Failed to create link session: ${response.status} ${text}`);
|
|
1526
|
+
}
|
|
1527
|
+
const body = (await response.json());
|
|
1528
|
+
return {
|
|
1529
|
+
token: body.token,
|
|
1530
|
+
appUrl: `${options.appUrl}/cli-auth?session=${encodeURIComponent(body.token)}`,
|
|
1531
|
+
pollUrl: `${options.apiUrl}/cli-link/session?token=${encodeURIComponent(body.token)}`,
|
|
1532
|
+
createdAt: body.createdAt,
|
|
1533
|
+
expiresAt: body.expiresAt,
|
|
1534
|
+
environment: options.environment,
|
|
1535
|
+
...(options.resolvedConfigPath
|
|
1536
|
+
? { resolvedConfigPath: options.resolvedConfigPath }
|
|
1537
|
+
: {}),
|
|
1538
|
+
envFilePath: options.envFilePath,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
async function pollLinkSessionOnce(spec) {
|
|
1542
|
+
const response = await fetch(spec.pollUrl);
|
|
1543
|
+
const body = (await response.json());
|
|
1544
|
+
const status = body.status ?? 'invalid';
|
|
1545
|
+
if (status === 'completed' && body.secret) {
|
|
1546
|
+
return { status, secret: body.secret };
|
|
1547
|
+
}
|
|
1548
|
+
if (status === 'pending' && new Date().toISOString() >= spec.expiresAt) {
|
|
1549
|
+
return { status: 'expired' };
|
|
1550
|
+
}
|
|
1551
|
+
return { status };
|
|
1407
1552
|
}
|
|
1408
|
-
|
|
1553
|
+
async function pollLinkSession(spec) {
|
|
1554
|
+
for (;;) {
|
|
1555
|
+
const result = await pollLinkSessionOnce(spec);
|
|
1556
|
+
if (result.status === 'completed' && result.secret) {
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
if (result.status === 'expired' ||
|
|
1560
|
+
result.status === 'consumed' ||
|
|
1561
|
+
result.status === 'invalid') {
|
|
1562
|
+
return result;
|
|
1563
|
+
}
|
|
1564
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, SCREENCI_LINK_SESSION_POLL_INTERVAL_MS));
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
|
|
1568
|
+
const interactive = opts.interactive ?? true;
|
|
1409
1569
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1410
1570
|
if (existingSecret)
|
|
1411
1571
|
return existingSecret;
|
|
1412
|
-
const appUrl = getDevFrontendUrl();
|
|
1413
1572
|
try {
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const savePath = resolvedConfigPath
|
|
1573
|
+
const environment = getScreenCIEnvironment();
|
|
1574
|
+
const apiUrl = getCliLinkSessionApiUrl();
|
|
1575
|
+
const appUrl = getDevFrontendUrl();
|
|
1576
|
+
const envFilePath = resolvedConfigPath
|
|
1419
1577
|
? await resolveProjectEnvFilePath(resolvedConfigPath)
|
|
1420
1578
|
: resolve(process.cwd(), '.env');
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1579
|
+
const projectDir = resolvedConfigPath
|
|
1580
|
+
? dirname(resolvedConfigPath)
|
|
1581
|
+
: process.cwd();
|
|
1582
|
+
const specPath = getLinkSessionFilePath(projectDir);
|
|
1583
|
+
const linkSessionContext = {
|
|
1584
|
+
environment,
|
|
1585
|
+
envFilePath,
|
|
1586
|
+
...(resolvedConfigPath ? { resolvedConfigPath } : {}),
|
|
1587
|
+
};
|
|
1588
|
+
const ensureSpec = async () => {
|
|
1589
|
+
const storedSpec = await readPersistedLinkSessionSpec(specPath);
|
|
1590
|
+
const spec = storedSpec &&
|
|
1591
|
+
isStoredLinkSessionReusable(storedSpec, linkSessionContext)
|
|
1592
|
+
? storedSpec
|
|
1593
|
+
: await createLinkSessionSpec({
|
|
1594
|
+
apiUrl,
|
|
1595
|
+
appUrl,
|
|
1596
|
+
...linkSessionContext,
|
|
1597
|
+
});
|
|
1598
|
+
if (spec !== storedSpec) {
|
|
1599
|
+
await writePersistedLinkSessionSpec(specPath, spec);
|
|
1600
|
+
}
|
|
1601
|
+
return spec;
|
|
1602
|
+
};
|
|
1603
|
+
const saveCompletedSecret = async (secret) => {
|
|
1604
|
+
process.env.SCREENCI_SECRET = secret;
|
|
1605
|
+
await persistScreenCISecret(envFilePath, secret);
|
|
1606
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
1607
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${envFilePath}`);
|
|
1608
|
+
return secret;
|
|
1609
|
+
};
|
|
1610
|
+
if (!interactive) {
|
|
1611
|
+
// Non-interactive sessions cannot complete a browser sign-in here, so we
|
|
1612
|
+
// never block. Reuse or create the persisted session and check its status
|
|
1613
|
+
// once; if a stored session is stale, recreate it and check once more.
|
|
1614
|
+
// When the session is already completed (the sign-in happened between
|
|
1615
|
+
// runs) we pick up the secret; otherwise we print the link and return so
|
|
1616
|
+
// the caller can surface it and rerun later.
|
|
1617
|
+
let spec = await ensureSpec();
|
|
1618
|
+
let result = await pollLinkSessionOnce(spec);
|
|
1619
|
+
if (result.status === 'expired' ||
|
|
1620
|
+
result.status === 'consumed' ||
|
|
1621
|
+
result.status === 'invalid') {
|
|
1622
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
1623
|
+
spec = await ensureSpec();
|
|
1624
|
+
result = await pollLinkSessionOnce(spec);
|
|
1625
|
+
}
|
|
1626
|
+
if (result.status === 'completed' && result.secret) {
|
|
1627
|
+
return await saveCompletedSecret(result.secret);
|
|
1628
|
+
}
|
|
1629
|
+
logger.info(`Sign-in required to record. Open this link to sign in and choose a plan:\n${pc.cyan(spec.appUrl)}\n` +
|
|
1630
|
+
`This session is non-interactive, so sign-in can't complete here. After signing in, rerun ${pc.cyan(getSuggestedScreenciCommand('record'))} to continue.`);
|
|
1631
|
+
return undefined;
|
|
1632
|
+
}
|
|
1633
|
+
for (;;) {
|
|
1634
|
+
const spec = await ensureSpec();
|
|
1635
|
+
logger.info(`Open this link to sign in and connect the CLI:\n${pc.cyan(spec.appUrl)}\n`);
|
|
1636
|
+
const result = await pollLinkSession(spec);
|
|
1637
|
+
if (result.status === 'completed' && result.secret) {
|
|
1638
|
+
return await saveCompletedSecret(result.secret);
|
|
1639
|
+
}
|
|
1640
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
1641
|
+
}
|
|
1424
1642
|
}
|
|
1425
1643
|
catch (err) {
|
|
1426
1644
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1427
1645
|
logger.warn(`Authentication failed: ${msg}`);
|
|
1428
|
-
logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${
|
|
1646
|
+
logger.info(`You can add SCREENCI_SECRET manually to ${resolvedConfigPath ? await resolveProjectEnvFilePath(resolvedConfigPath) : '.env'} later. Get it from ${getScreenCISecretsUrl()}.`);
|
|
1429
1647
|
logScreenCISecretGuide();
|
|
1430
1648
|
return undefined;
|
|
1431
1649
|
}
|
|
1432
1650
|
}
|
|
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
1651
|
export async function main() {
|
|
1457
1652
|
if (process.argv.length <= 2) {
|
|
1458
1653
|
logger.error('Error: No command provided');
|
|
1459
|
-
logger.error('Available commands:
|
|
1654
|
+
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1460
1655
|
process.exit(1);
|
|
1461
1656
|
}
|
|
1462
1657
|
const program = new Command();
|
|
1463
1658
|
const defaultPackageManager = determinePackageManager();
|
|
1464
1659
|
program.name('screenci');
|
|
1465
1660
|
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
1661
|
// record command — playwright args pass through as-is
|
|
1475
1662
|
program
|
|
1476
1663
|
.command('record [playwrightArgs...]')
|
|
@@ -1484,20 +1671,23 @@ export async function main() {
|
|
|
1484
1671
|
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose);
|
|
1485
1672
|
}
|
|
1486
1673
|
catch (error) {
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
playwrightFailure = error;
|
|
1674
|
+
if (!(error instanceof Error))
|
|
1675
|
+
throw error;
|
|
1676
|
+
if (error.message.startsWith('Playwright exited with code ')) {
|
|
1677
|
+
playwrightFailure = new RecordFailureHintError(error);
|
|
1491
1678
|
}
|
|
1492
1679
|
else {
|
|
1493
|
-
throw error;
|
|
1680
|
+
throw new RecordFailureHintError(error);
|
|
1494
1681
|
}
|
|
1495
1682
|
}
|
|
1496
1683
|
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1497
1684
|
return;
|
|
1498
|
-
// After recording, upload results to API if configured
|
|
1499
|
-
|
|
1500
|
-
|
|
1685
|
+
// After recording, upload results to API if configured. `run` already
|
|
1686
|
+
// resolved the config (or exited), so this best-effort lookup only acts
|
|
1687
|
+
// when a flat config is present in/under the current directory.
|
|
1688
|
+
const resolution = findScreenCIConfig(parsed.configPath);
|
|
1689
|
+
if (resolution.kind === 'found') {
|
|
1690
|
+
const resolvedConfigPath = resolution.path;
|
|
1501
1691
|
try {
|
|
1502
1692
|
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1503
1693
|
loadEnvFile(screenciConfig.envFile
|
|
@@ -1514,7 +1704,7 @@ export async function main() {
|
|
|
1514
1704
|
logger.info('All recordings failed.');
|
|
1515
1705
|
}
|
|
1516
1706
|
else if (!secret) {
|
|
1517
|
-
logger.info(`No SCREENCI_SECRET configured for uploads.
|
|
1707
|
+
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
1518
1708
|
}
|
|
1519
1709
|
else if (playwrightFailure !== null &&
|
|
1520
1710
|
uploadPolicy === 'all-or-nothing') {
|
|
@@ -1526,9 +1716,11 @@ export async function main() {
|
|
|
1526
1716
|
}
|
|
1527
1717
|
let uploadResult = {
|
|
1528
1718
|
projectId: null,
|
|
1719
|
+
recordId: null,
|
|
1529
1720
|
hadFailures: false,
|
|
1530
1721
|
failedVideoNames: [],
|
|
1531
1722
|
failedVideoMessages: [],
|
|
1723
|
+
studioNotices: [],
|
|
1532
1724
|
};
|
|
1533
1725
|
try {
|
|
1534
1726
|
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
@@ -1539,8 +1731,17 @@ export async function main() {
|
|
|
1539
1731
|
}
|
|
1540
1732
|
throw err;
|
|
1541
1733
|
}
|
|
1542
|
-
const { projectId, hadFailures, failedVideoNames, failedVideoMessages, } = uploadResult;
|
|
1543
|
-
if (projectId !== null) {
|
|
1734
|
+
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, } = uploadResult;
|
|
1735
|
+
if (recordId !== null && projectId !== null) {
|
|
1736
|
+
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
1737
|
+
await writeGitHubProjectOutput(recordUrl);
|
|
1738
|
+
logger.info('');
|
|
1739
|
+
logger.info(playwrightFailure !== null
|
|
1740
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1741
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
1742
|
+
logger.info(pc.cyan(recordUrl));
|
|
1743
|
+
}
|
|
1744
|
+
else if (projectId !== null) {
|
|
1544
1745
|
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1545
1746
|
await writeGitHubProjectOutput(projectUrl);
|
|
1546
1747
|
logger.info('');
|
|
@@ -1549,6 +1750,19 @@ export async function main() {
|
|
|
1549
1750
|
: 'Recording finished, rendering in progress. Results available at:');
|
|
1550
1751
|
logger.info(pc.cyan(projectUrl));
|
|
1551
1752
|
}
|
|
1753
|
+
for (const notice of studioNotices) {
|
|
1754
|
+
if ('held' in notice.studio) {
|
|
1755
|
+
logger.info('');
|
|
1756
|
+
logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
|
|
1757
|
+
if (projectId !== null && notice.videoId !== null) {
|
|
1758
|
+
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
else if (notice.studio.appliedChanges.length > 0) {
|
|
1762
|
+
logger.info('');
|
|
1763
|
+
logger.info(`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1552
1766
|
if (hadFailures) {
|
|
1553
1767
|
for (const failedVideo of failedVideoMessages) {
|
|
1554
1768
|
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
@@ -1580,8 +1794,11 @@ export async function main() {
|
|
|
1580
1794
|
.action(async () => {
|
|
1581
1795
|
const parsed = parseConfigCliArgs(getSubcommandArgv('test'));
|
|
1582
1796
|
let configMockRecord = false;
|
|
1583
|
-
|
|
1584
|
-
|
|
1797
|
+
// Best-effort env preload before handing off to `run`, which performs the
|
|
1798
|
+
// authoritative resolution (and emits the `cd screenci` guidance on miss).
|
|
1799
|
+
const resolution = findScreenCIConfig(parsed.configPath);
|
|
1800
|
+
if (resolution.kind === 'found') {
|
|
1801
|
+
const resolvedConfigPath = resolution.path;
|
|
1585
1802
|
try {
|
|
1586
1803
|
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1587
1804
|
configMockRecord = screenciConfig.test?.mockRecord ?? false;
|
|
@@ -1752,25 +1969,20 @@ function validateArgs(args) {
|
|
|
1752
1969
|
}
|
|
1753
1970
|
}
|
|
1754
1971
|
async function run(command, additionalArgs, customConfigPath, verbose = false, mockRecord = false) {
|
|
1755
|
-
const configPath =
|
|
1756
|
-
if (!configPath) {
|
|
1757
|
-
const errorMsg = customConfigPath
|
|
1758
|
-
? `Error: Config file not found: ${customConfigPath}`
|
|
1759
|
-
: 'Error: screenci.config.ts not found in current directory';
|
|
1760
|
-
logger.error(errorMsg);
|
|
1761
|
-
process.exit(1);
|
|
1762
|
-
}
|
|
1972
|
+
const configPath = resolveScreenCIConfigPathOrExit(customConfigPath);
|
|
1763
1973
|
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
|
|
1764
1974
|
await loadEnvFileFromConfigSource(configPath, false);
|
|
1765
1975
|
}
|
|
1766
1976
|
// Only validate args for record command
|
|
1767
1977
|
if (command === 'record') {
|
|
1768
1978
|
if (!process.env.SCREENCI_SECRET) {
|
|
1769
|
-
await requireScreenCISecret(configPath
|
|
1979
|
+
await requireScreenCISecret(configPath, {
|
|
1980
|
+
interactive: detectInteractiveSession(),
|
|
1981
|
+
});
|
|
1770
1982
|
}
|
|
1771
1983
|
validateArgs(additionalArgs);
|
|
1772
1984
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1773
|
-
|
|
1985
|
+
clearRecordingDirectories(screenciDir);
|
|
1774
1986
|
}
|
|
1775
1987
|
const envForChild = { ...process.env };
|
|
1776
1988
|
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
@@ -1788,7 +2000,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1788
2000
|
logger.info(`Using config: ${configPath}`);
|
|
1789
2001
|
}
|
|
1790
2002
|
const playwrightArgs = ['test', '--config', configPath, ...additionalArgs];
|
|
1791
|
-
const spawnSpec =
|
|
2003
|
+
const spawnSpec = resolvePlaywrightSpawnSpec(playwrightArgs, dirname(configPath));
|
|
1792
2004
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
1793
2005
|
stdio: 'inherit',
|
|
1794
2006
|
...(process.platform !== 'win32' ? { detached: true } : {}),
|
|
@@ -1843,9 +2055,21 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1843
2055
|
});
|
|
1844
2056
|
}
|
|
1845
2057
|
function logRecordFailureHint() {
|
|
2058
|
+
logger.info('');
|
|
1846
2059
|
logger.info(`If ${pc.cyan('screenci test')} works but ${pc.cyan('screenci record')} fails, try ${pc.cyan('screenci test --mock-record')}.`);
|
|
1847
2060
|
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`);
|
|
1848
2061
|
}
|
|
2062
|
+
export function logCliError(error) {
|
|
2063
|
+
if (isPartialUploadError(error)) {
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
const errorToLog = isRecordFailureHintError(error) ? error.cause : error;
|
|
2067
|
+
const message = errorToLog instanceof Error ? errorToLog.message : String(errorToLog);
|
|
2068
|
+
logger.error(message);
|
|
2069
|
+
if (isRecordFailureHintError(error)) {
|
|
2070
|
+
logRecordFailureHint();
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
1849
2073
|
// Only run if this file is being executed directly
|
|
1850
2074
|
// Check if this module is the main module (handles symlinks properly)
|
|
1851
2075
|
const currentFile = fileURLToPath(import.meta.url);
|
|
@@ -1856,10 +2080,7 @@ if (mainFile &&
|
|
|
1856
2080
|
currentRealFile === mainFile ||
|
|
1857
2081
|
currentFile === realpathSync(mainFile))) {
|
|
1858
2082
|
main().catch((error) => {
|
|
1859
|
-
|
|
1860
|
-
process.exit(1);
|
|
1861
|
-
}
|
|
1862
|
-
logger.error('Error:', error.message);
|
|
2083
|
+
logCliError(error);
|
|
1863
2084
|
process.exit(1);
|
|
1864
2085
|
});
|
|
1865
2086
|
}
|