screenci 0.0.54 → 0.0.56
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 +4 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +278 -87
- package/dist/cli.js.map +1 -1
- package/dist/docs/video-sources/installation.video.js +2 -1
- package/dist/docs/video-sources/installation.video.js.map +1 -1
- package/dist/docs/video-sources/landing.video.js +1 -1
- package/dist/docs/video-sources/landing.video.js.map +1 -1
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +21 -16
- package/dist/src/asset.js.map +1 -1
- package/dist/src/events.d.ts +3 -2
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +2 -1
- package/dist/src/events.js.map +1 -1
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +10 -8
- package/dist/src/init.js.map +1 -1
- package/dist/src/recordingData.d.ts +1 -0
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +3 -2
- package/dist/src/video.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/skills/screenci/SKILL.md +46 -6
- package/skills/screenci/references/init.md +11 -9
- package/skills/screenci/references/record.md +10 -1
- package/dist/scripts/ci-run-in-shell.d.ts +0 -2
- package/dist/scripts/ci-run-in-shell.d.ts.map +0 -1
- package/dist/scripts/ci-run-in-shell.js +0 -48
- package/dist/scripts/ci-run-in-shell.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { createReadStream } from 'fs';
|
|
3
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, } from 'fs';
|
|
4
4
|
import { createHash } from 'crypto';
|
|
5
5
|
import { createServer } from 'http';
|
|
6
|
-
import { appendFile, readdir, readFile, stat } from 'fs/promises';
|
|
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';
|
|
9
10
|
import { Command, CommanderError } from 'commander';
|
|
10
11
|
import pc from 'picocolors';
|
|
11
12
|
import { logger } from './src/logger.js';
|
|
@@ -14,6 +15,8 @@ import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, } fro
|
|
|
14
15
|
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
|
|
15
16
|
import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
|
|
16
17
|
const SCREENCI_MOCK_RECORD_DOCS_URL = 'https://screenci.com/docs/reference/cli/#--mock-record';
|
|
18
|
+
const SCREENCI_LOGIN_DOCS_URL = 'https://screenci.com/docs/reference/cli/#screenci-login';
|
|
19
|
+
const SCREENCI_SECRETS_URL = 'https://app.screenci.com/secrets';
|
|
17
20
|
export function collectPlaywrightListTitles(suites) {
|
|
18
21
|
const titles = [];
|
|
19
22
|
const visitSuite = (suite) => {
|
|
@@ -32,6 +35,9 @@ export function collectPlaywrightListTitles(suites) {
|
|
|
32
35
|
function parsePlaywrightListReport(stdout) {
|
|
33
36
|
return JSON.parse(stdout);
|
|
34
37
|
}
|
|
38
|
+
function logScreenCISecretGuide() {
|
|
39
|
+
logger.info(`Guide: ${pc.cyan(SCREENCI_LOGIN_DOCS_URL)}`);
|
|
40
|
+
}
|
|
35
41
|
async function collectDiscoveredTestTitles(configPath, additionalArgs, env) {
|
|
36
42
|
const listArgs = [
|
|
37
43
|
'test',
|
|
@@ -111,15 +117,19 @@ async function validateUniqueDiscoveredTestTitles(configPath, additionalArgs, en
|
|
|
111
117
|
throw new Error(formatDuplicateTitlesMessage(duplicates));
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
|
-
function resolveRecordingFileCandidates(filePath, configDir) {
|
|
120
|
+
function resolveRecordingFileCandidates(filePath, configDir, sourceFilePath) {
|
|
121
|
+
const sourceFileCandidate = typeof sourceFilePath === 'string'
|
|
122
|
+
? resolve(configDir, dirname(sourceFilePath), filePath)
|
|
123
|
+
: null;
|
|
115
124
|
return [
|
|
116
125
|
filePath,
|
|
126
|
+
...(sourceFileCandidate ? [sourceFileCandidate] : []),
|
|
117
127
|
resolve(configDir, 'videos', filePath),
|
|
118
128
|
resolve(configDir, pathRelative('/app', filePath)),
|
|
119
129
|
];
|
|
120
130
|
}
|
|
121
|
-
async function readRecordingFile(filePath, configDir) {
|
|
122
|
-
for (const candidate of resolveRecordingFileCandidates(filePath, configDir)) {
|
|
131
|
+
async function readRecordingFile(filePath, configDir, sourceFilePath) {
|
|
132
|
+
for (const candidate of resolveRecordingFileCandidates(filePath, configDir, sourceFilePath)) {
|
|
123
133
|
try {
|
|
124
134
|
return { buffer: await readFile(candidate), resolvedPath: candidate };
|
|
125
135
|
}
|
|
@@ -144,6 +154,12 @@ function contentTypeForPath(filePath) {
|
|
|
144
154
|
};
|
|
145
155
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
146
156
|
}
|
|
157
|
+
class UploadAssetError extends Error {
|
|
158
|
+
constructor(message) {
|
|
159
|
+
super(message);
|
|
160
|
+
this.name = 'UploadAssetError';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
147
163
|
class UploadCancelledError extends Error {
|
|
148
164
|
constructor(message = 'Upload cancelled') {
|
|
149
165
|
super(message);
|
|
@@ -164,6 +180,9 @@ function isUploadCancelledError(err) {
|
|
|
164
180
|
function isPartialUploadError(err) {
|
|
165
181
|
return err instanceof PartialUploadError;
|
|
166
182
|
}
|
|
183
|
+
function isUploadAssetError(err) {
|
|
184
|
+
return err instanceof UploadAssetError;
|
|
185
|
+
}
|
|
167
186
|
function supportsInPlaceUploadUpdates(verbose) {
|
|
168
187
|
return !verbose && process.stdout.isTTY === true && !process.env.CI;
|
|
169
188
|
}
|
|
@@ -248,12 +267,20 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
248
267
|
}
|
|
249
268
|
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex) {
|
|
250
269
|
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
270
|
+
let projectId = null;
|
|
251
271
|
try {
|
|
252
272
|
uploadAbort.throwIfAborted();
|
|
253
273
|
const recordingPath = resolve(screenciDir, entry, 'recording.mp4');
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
274
|
+
if (!existsSync(recordingPath)) {
|
|
275
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
276
|
+
return {
|
|
277
|
+
projectId: null,
|
|
278
|
+
hadFailure: true,
|
|
279
|
+
videoName,
|
|
280
|
+
failureMessage: `Missing recording.mp4 for "${videoName}"`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const recordingHash = await hashFile(recordingPath);
|
|
257
284
|
const startResponse = await fetch(`${apiUrl}/cli/upload/start`, {
|
|
258
285
|
method: 'POST',
|
|
259
286
|
headers: {
|
|
@@ -264,7 +291,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
264
291
|
projectName,
|
|
265
292
|
videoName,
|
|
266
293
|
data,
|
|
267
|
-
|
|
294
|
+
recordingHash,
|
|
268
295
|
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
269
296
|
fileHash: asset.fileHash,
|
|
270
297
|
size: asset.size,
|
|
@@ -287,53 +314,53 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
287
314
|
failureMessage: formatUploadStartFailureMessage(videoName, startResponse.status, text, secret),
|
|
288
315
|
};
|
|
289
316
|
}
|
|
290
|
-
const
|
|
317
|
+
const startBody = (await startResponse.json());
|
|
318
|
+
const { recordingId } = startBody;
|
|
319
|
+
projectId = startBody.projectId;
|
|
291
320
|
if (verbose) {
|
|
292
321
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
293
322
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
294
323
|
}
|
|
295
324
|
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId, uploadAbort.signal, uploadAbort.throwIfAborted, progressReporter);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
325
|
+
uploadAbort.throwIfAborted();
|
|
326
|
+
const fileStat = await stat(recordingPath);
|
|
327
|
+
if (verbose) {
|
|
328
|
+
logger.info(`Uploading recording.mp4 size=${(fileStat.size / 1024 / 1024).toFixed(1)}MB`);
|
|
329
|
+
}
|
|
330
|
+
const stream = createReadStream(recordingPath);
|
|
331
|
+
const abortStream = () => {
|
|
332
|
+
stream.destroy(new UploadCancelledError(`Upload cancelled for "${videoName}"`));
|
|
333
|
+
};
|
|
334
|
+
uploadAbort.signal.addEventListener('abort', abortStream, {
|
|
335
|
+
once: true,
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
const recordingResponse = await fetch(`${apiUrl}/cli/upload/${recordingId}/recording`, {
|
|
339
|
+
method: 'PUT',
|
|
340
|
+
headers: {
|
|
341
|
+
'Content-Type': 'video/mp4',
|
|
342
|
+
'Content-Length': String(fileStat.size),
|
|
343
|
+
'X-ScreenCI-Secret': secret,
|
|
344
|
+
},
|
|
345
|
+
body: stream,
|
|
346
|
+
signal: uploadAbort.signal,
|
|
347
|
+
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
348
|
+
duplex: 'half',
|
|
308
349
|
});
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
},
|
|
317
|
-
|
|
318
|
-
signal: uploadAbort.signal,
|
|
319
|
-
// @ts-expect-error Node.js fetch supports duplex for streaming
|
|
320
|
-
duplex: 'half',
|
|
321
|
-
});
|
|
322
|
-
if (!recordingResponse.ok) {
|
|
323
|
-
const text = await recordingResponse.text();
|
|
324
|
-
progressReporter.complete(progressIndex, 'failure');
|
|
325
|
-
return {
|
|
326
|
-
projectId,
|
|
327
|
-
hadFailure: true,
|
|
328
|
-
videoName,
|
|
329
|
-
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
finally {
|
|
334
|
-
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
350
|
+
if (!recordingResponse.ok) {
|
|
351
|
+
const text = await recordingResponse.text();
|
|
352
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
353
|
+
return {
|
|
354
|
+
projectId,
|
|
355
|
+
hadFailure: true,
|
|
356
|
+
videoName,
|
|
357
|
+
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
358
|
+
};
|
|
335
359
|
}
|
|
336
360
|
}
|
|
361
|
+
finally {
|
|
362
|
+
uploadAbort.signal.removeEventListener('abort', abortStream);
|
|
363
|
+
}
|
|
337
364
|
progressReporter.complete(progressIndex, 'success');
|
|
338
365
|
return { projectId, hadFailure: false, videoName };
|
|
339
366
|
}
|
|
@@ -342,9 +369,18 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
342
369
|
progressReporter.complete(progressIndex, 'cancelled');
|
|
343
370
|
throw err;
|
|
344
371
|
}
|
|
372
|
+
if (isUploadAssetError(err)) {
|
|
373
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
374
|
+
return {
|
|
375
|
+
projectId,
|
|
376
|
+
hadFailure: true,
|
|
377
|
+
videoName,
|
|
378
|
+
failureMessage: err instanceof Error ? err.message : String(err),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
345
381
|
progressReporter.complete(progressIndex, 'failure');
|
|
346
382
|
return {
|
|
347
|
-
projectId
|
|
383
|
+
projectId,
|
|
348
384
|
hadFailure: true,
|
|
349
385
|
videoName,
|
|
350
386
|
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -548,6 +584,7 @@ async function hashFile(filePath) {
|
|
|
548
584
|
});
|
|
549
585
|
}
|
|
550
586
|
async function prepareCustomVoiceAssets(data, configDir) {
|
|
587
|
+
const sourceFilePath = data.metadata?.sourceFilePath;
|
|
551
588
|
const customVoiceRefsByPath = new Map();
|
|
552
589
|
for (const event of data.events) {
|
|
553
590
|
if (event.type === 'cueStart' && event.translations) {
|
|
@@ -580,7 +617,7 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
580
617
|
}
|
|
581
618
|
const preparedAssets = [];
|
|
582
619
|
for (const [voicePath, refs] of customVoiceRefsByPath) {
|
|
583
|
-
const resolvedFile = await readRecordingFile(voicePath, configDir);
|
|
620
|
+
const resolvedFile = await readRecordingFile(voicePath, configDir, sourceFilePath);
|
|
584
621
|
if (resolvedFile === null) {
|
|
585
622
|
const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
|
|
586
623
|
if (!existingHash) {
|
|
@@ -615,12 +652,13 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
615
652
|
return preparedAssets;
|
|
616
653
|
}
|
|
617
654
|
async function collectUploadAssets(data, configDir) {
|
|
655
|
+
const sourceFilePath = data.metadata?.sourceFilePath;
|
|
618
656
|
const assets = new Map();
|
|
619
657
|
for (const event of data.events) {
|
|
620
658
|
if (event.type === 'assetStart') {
|
|
621
659
|
if (assets.has(`name:${event.name}`))
|
|
622
660
|
continue;
|
|
623
|
-
const resolvedFile = await readRecordingFile(event.path, configDir);
|
|
661
|
+
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
624
662
|
if (resolvedFile === null) {
|
|
625
663
|
logger.warn(`Asset file not found, skipping upload: ${event.path}`);
|
|
626
664
|
continue;
|
|
@@ -641,7 +679,7 @@ async function collectUploadAssets(data, configDir) {
|
|
|
641
679
|
if (typeof event.assetHash === 'string' &&
|
|
642
680
|
!assets.has(`hash:${event.assetHash}`)) {
|
|
643
681
|
const resolvedFile = typeof event.assetPath === 'string'
|
|
644
|
-
? await readRecordingFile(event.assetPath, configDir)
|
|
682
|
+
? await readRecordingFile(event.assetPath, configDir, sourceFilePath)
|
|
645
683
|
: null;
|
|
646
684
|
assets.set(`hash:${event.assetHash}`, {
|
|
647
685
|
fileHash: event.assetHash,
|
|
@@ -663,7 +701,7 @@ async function collectUploadAssets(data, configDir) {
|
|
|
663
701
|
!assets.has(`hash:${translation.assetHash}`)) {
|
|
664
702
|
const resolvedFile = 'assetPath' in translation &&
|
|
665
703
|
typeof translation.assetPath === 'string'
|
|
666
|
-
? await readRecordingFile(translation.assetPath, configDir)
|
|
704
|
+
? await readRecordingFile(translation.assetPath, configDir, sourceFilePath)
|
|
667
705
|
: null;
|
|
668
706
|
assets.set(`hash:${translation.assetHash}`, {
|
|
669
707
|
fileHash: translation.assetHash,
|
|
@@ -820,8 +858,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
820
858
|
});
|
|
821
859
|
if (!checkRes.ok) {
|
|
822
860
|
const text = await checkRes.text();
|
|
823
|
-
|
|
824
|
-
continue;
|
|
861
|
+
throw new UploadAssetError(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
|
|
825
862
|
}
|
|
826
863
|
const checkBody = (await checkRes.json());
|
|
827
864
|
if (checkBody.exists) {
|
|
@@ -829,8 +866,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
829
866
|
continue;
|
|
830
867
|
}
|
|
831
868
|
if (!asset.fileBuffer || !asset.contentType) {
|
|
832
|
-
|
|
833
|
-
continue;
|
|
869
|
+
throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
|
|
834
870
|
}
|
|
835
871
|
throwIfAborted();
|
|
836
872
|
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
|
|
@@ -855,7 +891,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
855
891
|
logInfo(`Asset already exists: ${asset.path}`);
|
|
856
892
|
}
|
|
857
893
|
else {
|
|
858
|
-
|
|
894
|
+
throw new UploadAssetError(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
859
895
|
}
|
|
860
896
|
}
|
|
861
897
|
else {
|
|
@@ -866,7 +902,10 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
866
902
|
if (isUploadCancelledError(err)) {
|
|
867
903
|
throw err;
|
|
868
904
|
}
|
|
869
|
-
|
|
905
|
+
if (isUploadAssetError(err)) {
|
|
906
|
+
throw err;
|
|
907
|
+
}
|
|
908
|
+
throw new UploadAssetError(`Network error uploading asset ${asset.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
870
909
|
}
|
|
871
910
|
}
|
|
872
911
|
}
|
|
@@ -971,14 +1010,21 @@ async function loadScreenCIConfigAndEnv(configPath) {
|
|
|
971
1010
|
process.exit(1);
|
|
972
1011
|
}
|
|
973
1012
|
if (screenciConfig.envFile) {
|
|
974
|
-
|
|
975
|
-
|
|
1013
|
+
loadEnvFile(resolve(dirname(resolvedConfigPath), screenciConfig.envFile), true);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
loadEnvFile(resolve(dirname(resolvedConfigPath), '.env'), false);
|
|
976
1017
|
}
|
|
977
1018
|
return { resolvedConfigPath, screenciConfig };
|
|
978
1019
|
}
|
|
979
1020
|
function loadEnvFile(envFilePath, warnOnFailure) {
|
|
980
1021
|
try {
|
|
981
|
-
process.loadEnvFile
|
|
1022
|
+
const loadEnvFileCompat = process.loadEnvFile;
|
|
1023
|
+
if (typeof loadEnvFileCompat === 'function') {
|
|
1024
|
+
loadEnvFileCompat(envFilePath);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
loadEnvFileFallback(envFilePath);
|
|
982
1028
|
}
|
|
983
1029
|
catch (err) {
|
|
984
1030
|
if (warnOnFailure && !isMissingFileError(err)) {
|
|
@@ -986,6 +1032,58 @@ function loadEnvFile(envFilePath, warnOnFailure) {
|
|
|
986
1032
|
}
|
|
987
1033
|
}
|
|
988
1034
|
}
|
|
1035
|
+
function loadEnvFileFallback(envFilePath) {
|
|
1036
|
+
const envSource = readFileSync(envFilePath, 'utf8');
|
|
1037
|
+
for (const [key, value] of parseEnvFile(envSource)) {
|
|
1038
|
+
if (process.env[key] === undefined) {
|
|
1039
|
+
process.env[key] = value;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function parseEnvFile(envSource) {
|
|
1044
|
+
const parsed = new Map();
|
|
1045
|
+
const lines = envSource.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
1046
|
+
for (const rawLine of lines) {
|
|
1047
|
+
const line = rawLine.trim();
|
|
1048
|
+
if (line === '' || line.startsWith('#'))
|
|
1049
|
+
continue;
|
|
1050
|
+
const normalizedLine = line.startsWith('export ')
|
|
1051
|
+
? line.slice('export '.length).trimStart()
|
|
1052
|
+
: line;
|
|
1053
|
+
const separatorIndex = normalizedLine.indexOf('=');
|
|
1054
|
+
if (separatorIndex === -1)
|
|
1055
|
+
continue;
|
|
1056
|
+
const key = normalizedLine.slice(0, separatorIndex).trim();
|
|
1057
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1058
|
+
continue;
|
|
1059
|
+
const rawValue = normalizedLine.slice(separatorIndex + 1).trim();
|
|
1060
|
+
parsed.set(key, parseEnvValue(rawValue));
|
|
1061
|
+
}
|
|
1062
|
+
return parsed;
|
|
1063
|
+
}
|
|
1064
|
+
function parseEnvValue(rawValue) {
|
|
1065
|
+
if (rawValue === '')
|
|
1066
|
+
return '';
|
|
1067
|
+
if ((rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
|
1068
|
+
(rawValue.startsWith("'") && rawValue.endsWith("'"))) {
|
|
1069
|
+
const quote = rawValue[0];
|
|
1070
|
+
const quotedValue = rawValue.slice(1, -1);
|
|
1071
|
+
if (quote === '"') {
|
|
1072
|
+
return quotedValue
|
|
1073
|
+
.replace(/\\n/g, '\n')
|
|
1074
|
+
.replace(/\\r/g, '\r')
|
|
1075
|
+
.replace(/\\t/g, '\t')
|
|
1076
|
+
.replace(/\\"/g, '"')
|
|
1077
|
+
.replace(/\\\\/g, '\\');
|
|
1078
|
+
}
|
|
1079
|
+
return quotedValue;
|
|
1080
|
+
}
|
|
1081
|
+
const inlineCommentIndex = rawValue.search(/\s#/);
|
|
1082
|
+
if (inlineCommentIndex >= 0) {
|
|
1083
|
+
return rawValue.slice(0, inlineCommentIndex).trimEnd();
|
|
1084
|
+
}
|
|
1085
|
+
return rawValue;
|
|
1086
|
+
}
|
|
989
1087
|
function isMissingFileError(err) {
|
|
990
1088
|
return (typeof err === 'object' &&
|
|
991
1089
|
err !== null &&
|
|
@@ -995,10 +1093,9 @@ function isMissingFileError(err) {
|
|
|
995
1093
|
async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
996
1094
|
try {
|
|
997
1095
|
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1096
|
+
loadEnvFile(screenciConfig.envFile
|
|
1097
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
1098
|
+
: resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
|
|
1002
1099
|
}
|
|
1003
1100
|
catch {
|
|
1004
1101
|
// Config import may require Playwright context or dynamic values. Continue with
|
|
@@ -1016,6 +1113,10 @@ async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
|
1016
1113
|
return undefined;
|
|
1017
1114
|
}
|
|
1018
1115
|
}
|
|
1116
|
+
async function resolveProjectEnvFilePath(resolvedConfigPath) {
|
|
1117
|
+
return ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1118
|
+
resolve(dirname(resolvedConfigPath), '.env'));
|
|
1119
|
+
}
|
|
1019
1120
|
export function extractConfigStringLiteral(configSource, property) {
|
|
1020
1121
|
const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
|
|
1021
1122
|
if (singleQuoteMatch)
|
|
@@ -1094,7 +1195,9 @@ async function requireScreenCISecret(configPath) {
|
|
|
1094
1195
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1095
1196
|
const secret = process.env.SCREENCI_SECRET;
|
|
1096
1197
|
if (!secret) {
|
|
1097
|
-
|
|
1198
|
+
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1199
|
+
logger.error(`No SCREENCI_SECRET configured. Run ${pc.cyan('screenci login')} or add SCREENCI_SECRET to ${envFilePath}. You can get the secret manually from ${SCREENCI_SECRETS_URL}.`);
|
|
1200
|
+
logScreenCISecretGuide();
|
|
1098
1201
|
process.exit(1);
|
|
1099
1202
|
}
|
|
1100
1203
|
return {
|
|
@@ -1155,8 +1258,47 @@ function openBrowser(url) {
|
|
|
1155
1258
|
logger.warn('Failed to open browser automatically:', err);
|
|
1156
1259
|
}
|
|
1157
1260
|
}
|
|
1158
|
-
async function
|
|
1261
|
+
async function promptToOpenLoginUrl() {
|
|
1262
|
+
return await confirm({
|
|
1263
|
+
message: 'Open this link in your browser now?',
|
|
1264
|
+
default: false,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
async function persistScreenCISecret(envFilePath, secret) {
|
|
1268
|
+
const nextLine = `SCREENCI_SECRET=${secret}`;
|
|
1269
|
+
try {
|
|
1270
|
+
const existing = await readFile(envFilePath, 'utf-8');
|
|
1271
|
+
const lines = existing === '' ? [] : existing.split(/\r?\n/);
|
|
1272
|
+
const firstSecretIndex = lines.findIndex((line) => line.startsWith('SCREENCI_SECRET='));
|
|
1273
|
+
const linesWithoutSecret = lines.filter((line) => !line.startsWith('SCREENCI_SECRET='));
|
|
1274
|
+
const finalLines = firstSecretIndex >= 0
|
|
1275
|
+
? [
|
|
1276
|
+
...linesWithoutSecret.slice(0, firstSecretIndex),
|
|
1277
|
+
nextLine,
|
|
1278
|
+
...linesWithoutSecret.slice(firstSecretIndex),
|
|
1279
|
+
]
|
|
1280
|
+
: [...linesWithoutSecret, nextLine];
|
|
1281
|
+
let nextContent = finalLines.join('\n');
|
|
1282
|
+
if (!nextContent.endsWith('\n'))
|
|
1283
|
+
nextContent += '\n';
|
|
1284
|
+
await writeFile(envFilePath, nextContent);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
catch (err) {
|
|
1288
|
+
if (!isMissingFileError(err))
|
|
1289
|
+
throw err;
|
|
1290
|
+
}
|
|
1291
|
+
await writeFile(envFilePath, `${nextLine}\n`);
|
|
1292
|
+
}
|
|
1293
|
+
async function performBrowserLogin(appUrl, options) {
|
|
1159
1294
|
return new Promise((resolve, reject) => {
|
|
1295
|
+
let settled = false;
|
|
1296
|
+
const finish = (callback) => {
|
|
1297
|
+
if (settled)
|
|
1298
|
+
return;
|
|
1299
|
+
settled = true;
|
|
1300
|
+
callback();
|
|
1301
|
+
};
|
|
1160
1302
|
const server = createServer((req, res) => {
|
|
1161
1303
|
try {
|
|
1162
1304
|
const reqUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
@@ -1165,34 +1307,48 @@ async function performBrowserLogin(appUrl) {
|
|
|
1165
1307
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1166
1308
|
res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="font-size:1.2rem">Setup complete! You can close this tab.</p></body></html>');
|
|
1167
1309
|
server.close();
|
|
1168
|
-
resolve(secret);
|
|
1310
|
+
finish(() => resolve(secret));
|
|
1169
1311
|
}
|
|
1170
1312
|
else {
|
|
1171
1313
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
1172
1314
|
res.end('<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><p style="color:red;font-size:1.2rem">Authentication failed: no secret received. Please try again.</p></body></html>');
|
|
1173
1315
|
server.close();
|
|
1174
|
-
reject(new Error('No secret received in callback'));
|
|
1316
|
+
finish(() => reject(new Error('No secret received in callback')));
|
|
1175
1317
|
}
|
|
1176
1318
|
}
|
|
1177
1319
|
catch (err) {
|
|
1178
1320
|
res.writeHead(500);
|
|
1179
1321
|
res.end('Internal error');
|
|
1180
1322
|
server.close();
|
|
1181
|
-
reject(err);
|
|
1323
|
+
finish(() => reject(err));
|
|
1182
1324
|
}
|
|
1183
1325
|
});
|
|
1184
1326
|
server.listen(0, '127.0.0.1', () => {
|
|
1185
1327
|
const port = server.address().port;
|
|
1186
1328
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
1187
1329
|
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
1188
|
-
logger.info(
|
|
1330
|
+
logger.info('Open this link to log in to ScreenCI:');
|
|
1189
1331
|
logger.info(pc.cyan(loginUrl));
|
|
1190
1332
|
logger.info('');
|
|
1191
|
-
|
|
1333
|
+
void (async () => {
|
|
1334
|
+
if (options?.openBrowserImmediately) {
|
|
1335
|
+
openBrowser(loginUrl);
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const shouldOpen = await promptToOpenLoginUrl();
|
|
1339
|
+
if (shouldOpen) {
|
|
1340
|
+
openBrowser(loginUrl);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
|
|
1344
|
+
})().catch((err) => {
|
|
1345
|
+
server.close();
|
|
1346
|
+
finish(() => reject(err));
|
|
1347
|
+
});
|
|
1192
1348
|
});
|
|
1193
1349
|
const timeout = setTimeout(() => {
|
|
1194
1350
|
server.close();
|
|
1195
|
-
reject(new Error('Authentication timed out after
|
|
1351
|
+
finish(() => reject(new Error('Authentication timed out after 15 minutes')));
|
|
1196
1352
|
}, 15 * 60 * 1000);
|
|
1197
1353
|
server.on('close', () => clearTimeout(timeout));
|
|
1198
1354
|
});
|
|
@@ -1201,36 +1357,68 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
|
1201
1357
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1202
1358
|
if (existingSecret)
|
|
1203
1359
|
return existingSecret;
|
|
1204
|
-
logger.info('Opening browser for authentication to get your SCREENCI_SECRET...');
|
|
1205
1360
|
const appUrl = getDevFrontendUrl();
|
|
1206
1361
|
try {
|
|
1207
|
-
const secret = await performBrowserLogin(appUrl
|
|
1362
|
+
const secret = await performBrowserLogin(appUrl, {
|
|
1363
|
+
openBrowserImmediately: true,
|
|
1364
|
+
});
|
|
1208
1365
|
process.env.SCREENCI_SECRET = secret;
|
|
1209
1366
|
const savePath = resolvedConfigPath
|
|
1210
|
-
?
|
|
1211
|
-
resolve(process.cwd(), '.env'))
|
|
1367
|
+
? await resolveProjectEnvFilePath(resolvedConfigPath)
|
|
1212
1368
|
: resolve(process.cwd(), '.env');
|
|
1213
|
-
await
|
|
1369
|
+
await persistScreenCISecret(savePath, secret);
|
|
1214
1370
|
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1215
1371
|
return secret;
|
|
1216
1372
|
}
|
|
1217
1373
|
catch (err) {
|
|
1218
1374
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1219
1375
|
logger.warn(`Authentication failed: ${msg}`);
|
|
1220
|
-
logger.info(
|
|
1376
|
+
logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1377
|
+
logScreenCISecretGuide();
|
|
1221
1378
|
return undefined;
|
|
1222
1379
|
}
|
|
1223
1380
|
}
|
|
1381
|
+
async function runLogin(configPath, open = false) {
|
|
1382
|
+
const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
|
|
1383
|
+
if (process.env.SCREENCI_SECRET) {
|
|
1384
|
+
logger.info('SCREENCI_SECRET is already configured.');
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1388
|
+
const appUrl = getDevFrontendUrl();
|
|
1389
|
+
try {
|
|
1390
|
+
const secret = await performBrowserLogin(appUrl, {
|
|
1391
|
+
openBrowserImmediately: open,
|
|
1392
|
+
});
|
|
1393
|
+
process.env.SCREENCI_SECRET = secret;
|
|
1394
|
+
await persistScreenCISecret(savePath, secret);
|
|
1395
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1399
|
+
logger.warn(`Authentication failed: ${msg}`);
|
|
1400
|
+
logger.info(`You can run ${pc.cyan('screenci login')} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1401
|
+
logScreenCISecretGuide();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1224
1404
|
export async function main() {
|
|
1225
1405
|
if (process.argv.length <= 2) {
|
|
1226
1406
|
logger.error('Error: No command provided');
|
|
1227
|
-
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1407
|
+
logger.error('Available commands: login, record, test, info, make-public, make-private, init');
|
|
1228
1408
|
process.exit(1);
|
|
1229
1409
|
}
|
|
1230
1410
|
const program = new Command();
|
|
1231
1411
|
const defaultPackageManager = determinePackageManager();
|
|
1232
1412
|
program.name('screenci');
|
|
1233
1413
|
program.exitOverride();
|
|
1414
|
+
program
|
|
1415
|
+
.command('login')
|
|
1416
|
+
.description('Authenticate and save SCREENCI_SECRET for this project')
|
|
1417
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1418
|
+
.option('--open', 'open the login URL in your browser immediately')
|
|
1419
|
+
.action(async (options) => {
|
|
1420
|
+
await runLogin(options.config, options.open === true);
|
|
1421
|
+
});
|
|
1234
1422
|
// record command — playwright args pass through as-is
|
|
1235
1423
|
program
|
|
1236
1424
|
.command('record [playwrightArgs...]')
|
|
@@ -1260,10 +1448,9 @@ export async function main() {
|
|
|
1260
1448
|
if (resolvedConfigPath) {
|
|
1261
1449
|
try {
|
|
1262
1450
|
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1451
|
+
loadEnvFile(screenciConfig.envFile
|
|
1452
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
1453
|
+
: resolve(dirname(resolvedConfigPath), '.env'), true);
|
|
1267
1454
|
const apiUrl = getDevBackendUrl();
|
|
1268
1455
|
const appUrl = getDevFrontendUrl();
|
|
1269
1456
|
const secret = process.env.SCREENCI_SECRET;
|
|
@@ -1275,7 +1462,7 @@ export async function main() {
|
|
|
1275
1462
|
logger.info('All recordings failed.');
|
|
1276
1463
|
}
|
|
1277
1464
|
else if (!secret) {
|
|
1278
|
-
logger.info('No
|
|
1465
|
+
logger.info('No SCREENCI_SECRET configured for uploads. Run screenci login or add it to the project env file.');
|
|
1279
1466
|
}
|
|
1280
1467
|
else if (playwrightFailure !== null &&
|
|
1281
1468
|
uploadPolicy === 'all-or-nothing') {
|
|
@@ -1530,7 +1717,9 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1530
1717
|
}
|
|
1531
1718
|
// Only validate args for record command
|
|
1532
1719
|
if (command === 'record') {
|
|
1533
|
-
|
|
1720
|
+
if (!process.env.SCREENCI_SECRET) {
|
|
1721
|
+
await requireScreenCISecret(configPath);
|
|
1722
|
+
}
|
|
1534
1723
|
validateArgs(additionalArgs);
|
|
1535
1724
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1536
1725
|
clearDirectory(screenciDir);
|
|
@@ -1538,6 +1727,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1538
1727
|
const envForChild = { ...process.env };
|
|
1539
1728
|
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
1540
1729
|
...envForChild,
|
|
1730
|
+
SCREENCI_CONFIG_DIR: dirname(configPath),
|
|
1541
1731
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1542
1732
|
...(command === 'test' && !mockRecord
|
|
1543
1733
|
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
@@ -1562,6 +1752,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1562
1752
|
: {}),
|
|
1563
1753
|
env: {
|
|
1564
1754
|
...envForChild,
|
|
1755
|
+
SCREENCI_CONFIG_DIR: dirname(configPath),
|
|
1565
1756
|
// Enable recording only for record command
|
|
1566
1757
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1567
1758
|
...(command === 'test' && !mockRecord
|