screenci 0.0.53 → 0.0.55
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 +219 -85
- package/dist/cli.js.map +1 -1
- package/dist/docs/video-sources/cli.video.js +6 -6
- package/dist/docs/video-sources/cli.video.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 +8 -5
- 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/README.md
CHANGED
|
@@ -79,11 +79,13 @@ Playwright without starting the final recording and upload path.
|
|
|
79
79
|
## Record the final output
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
+
npx screenci login
|
|
82
83
|
npx screenci record
|
|
83
84
|
```
|
|
84
85
|
|
|
85
|
-
`
|
|
86
|
-
when
|
|
86
|
+
`login` saves `SCREENCI_SECRET` into the project env file. `record` writes local
|
|
87
|
+
artifacts into `.screenci/<video-name>/` and uploads them when that secret is
|
|
88
|
+
configured.
|
|
87
89
|
|
|
88
90
|
## Configure
|
|
89
91
|
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../cli.ts"],"names":[],"mappings":"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"}
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,10 @@ import { createReadStream } from 'fs';
|
|
|
3
3
|
import { existsSync, mkdirSync, 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,8 +1010,10 @@ 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
|
}
|
|
@@ -995,10 +1036,9 @@ function isMissingFileError(err) {
|
|
|
995
1036
|
async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
996
1037
|
try {
|
|
997
1038
|
const screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1039
|
+
loadEnvFile(screenciConfig.envFile
|
|
1040
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
1041
|
+
: resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
|
|
1002
1042
|
}
|
|
1003
1043
|
catch {
|
|
1004
1044
|
// Config import may require Playwright context or dynamic values. Continue with
|
|
@@ -1016,6 +1056,10 @@ async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
|
1016
1056
|
return undefined;
|
|
1017
1057
|
}
|
|
1018
1058
|
}
|
|
1059
|
+
async function resolveProjectEnvFilePath(resolvedConfigPath) {
|
|
1060
|
+
return ((await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1061
|
+
resolve(dirname(resolvedConfigPath), '.env'));
|
|
1062
|
+
}
|
|
1019
1063
|
export function extractConfigStringLiteral(configSource, property) {
|
|
1020
1064
|
const singleQuoteMatch = configSource.match(new RegExp(property + "\\s*:\\s*'([^'\\n]+)'"));
|
|
1021
1065
|
if (singleQuoteMatch)
|
|
@@ -1094,7 +1138,9 @@ async function requireScreenCISecret(configPath) {
|
|
|
1094
1138
|
const { resolvedConfigPath, screenciConfig } = await loadScreenCIConfigAndEnv(configPath);
|
|
1095
1139
|
const secret = process.env.SCREENCI_SECRET;
|
|
1096
1140
|
if (!secret) {
|
|
1097
|
-
|
|
1141
|
+
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1142
|
+
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}.`);
|
|
1143
|
+
logScreenCISecretGuide();
|
|
1098
1144
|
process.exit(1);
|
|
1099
1145
|
}
|
|
1100
1146
|
return {
|
|
@@ -1155,8 +1201,47 @@ function openBrowser(url) {
|
|
|
1155
1201
|
logger.warn('Failed to open browser automatically:', err);
|
|
1156
1202
|
}
|
|
1157
1203
|
}
|
|
1158
|
-
async function
|
|
1204
|
+
async function promptToOpenLoginUrl() {
|
|
1205
|
+
return await confirm({
|
|
1206
|
+
message: 'Open this link in your browser now?',
|
|
1207
|
+
default: false,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
async function persistScreenCISecret(envFilePath, secret) {
|
|
1211
|
+
const nextLine = `SCREENCI_SECRET=${secret}`;
|
|
1212
|
+
try {
|
|
1213
|
+
const existing = await readFile(envFilePath, 'utf-8');
|
|
1214
|
+
const lines = existing === '' ? [] : existing.split(/\r?\n/);
|
|
1215
|
+
const firstSecretIndex = lines.findIndex((line) => line.startsWith('SCREENCI_SECRET='));
|
|
1216
|
+
const linesWithoutSecret = lines.filter((line) => !line.startsWith('SCREENCI_SECRET='));
|
|
1217
|
+
const finalLines = firstSecretIndex >= 0
|
|
1218
|
+
? [
|
|
1219
|
+
...linesWithoutSecret.slice(0, firstSecretIndex),
|
|
1220
|
+
nextLine,
|
|
1221
|
+
...linesWithoutSecret.slice(firstSecretIndex),
|
|
1222
|
+
]
|
|
1223
|
+
: [...linesWithoutSecret, nextLine];
|
|
1224
|
+
let nextContent = finalLines.join('\n');
|
|
1225
|
+
if (!nextContent.endsWith('\n'))
|
|
1226
|
+
nextContent += '\n';
|
|
1227
|
+
await writeFile(envFilePath, nextContent);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
if (!isMissingFileError(err))
|
|
1232
|
+
throw err;
|
|
1233
|
+
}
|
|
1234
|
+
await writeFile(envFilePath, `${nextLine}\n`);
|
|
1235
|
+
}
|
|
1236
|
+
async function performBrowserLogin(appUrl, options) {
|
|
1159
1237
|
return new Promise((resolve, reject) => {
|
|
1238
|
+
let settled = false;
|
|
1239
|
+
const finish = (callback) => {
|
|
1240
|
+
if (settled)
|
|
1241
|
+
return;
|
|
1242
|
+
settled = true;
|
|
1243
|
+
callback();
|
|
1244
|
+
};
|
|
1160
1245
|
const server = createServer((req, res) => {
|
|
1161
1246
|
try {
|
|
1162
1247
|
const reqUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
@@ -1165,34 +1250,48 @@ async function performBrowserLogin(appUrl) {
|
|
|
1165
1250
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1166
1251
|
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
1252
|
server.close();
|
|
1168
|
-
resolve(secret);
|
|
1253
|
+
finish(() => resolve(secret));
|
|
1169
1254
|
}
|
|
1170
1255
|
else {
|
|
1171
1256
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
1172
1257
|
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
1258
|
server.close();
|
|
1174
|
-
reject(new Error('No secret received in callback'));
|
|
1259
|
+
finish(() => reject(new Error('No secret received in callback')));
|
|
1175
1260
|
}
|
|
1176
1261
|
}
|
|
1177
1262
|
catch (err) {
|
|
1178
1263
|
res.writeHead(500);
|
|
1179
1264
|
res.end('Internal error');
|
|
1180
1265
|
server.close();
|
|
1181
|
-
reject(err);
|
|
1266
|
+
finish(() => reject(err));
|
|
1182
1267
|
}
|
|
1183
1268
|
});
|
|
1184
1269
|
server.listen(0, '127.0.0.1', () => {
|
|
1185
1270
|
const port = server.address().port;
|
|
1186
1271
|
const callbackUrl = `http://localhost:${port}/callback`;
|
|
1187
1272
|
const loginUrl = `${appUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
|
|
1188
|
-
logger.info(
|
|
1273
|
+
logger.info('Open this link to log in to ScreenCI:');
|
|
1189
1274
|
logger.info(pc.cyan(loginUrl));
|
|
1190
1275
|
logger.info('');
|
|
1191
|
-
|
|
1276
|
+
void (async () => {
|
|
1277
|
+
if (options?.openBrowserImmediately) {
|
|
1278
|
+
openBrowser(loginUrl);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const shouldOpen = await promptToOpenLoginUrl();
|
|
1282
|
+
if (shouldOpen) {
|
|
1283
|
+
openBrowser(loginUrl);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
logger.info('Browser not opened. Keep this command running and open the link manually to continue.');
|
|
1287
|
+
})().catch((err) => {
|
|
1288
|
+
server.close();
|
|
1289
|
+
finish(() => reject(err));
|
|
1290
|
+
});
|
|
1192
1291
|
});
|
|
1193
1292
|
const timeout = setTimeout(() => {
|
|
1194
1293
|
server.close();
|
|
1195
|
-
reject(new Error('Authentication timed out after
|
|
1294
|
+
finish(() => reject(new Error('Authentication timed out after 15 minutes')));
|
|
1196
1295
|
}, 15 * 60 * 1000);
|
|
1197
1296
|
server.on('close', () => clearTimeout(timeout));
|
|
1198
1297
|
});
|
|
@@ -1201,36 +1300,68 @@ export async function ensureScreenciSecret(resolvedConfigPath) {
|
|
|
1201
1300
|
const existingSecret = process.env.SCREENCI_SECRET;
|
|
1202
1301
|
if (existingSecret)
|
|
1203
1302
|
return existingSecret;
|
|
1204
|
-
logger.info('Opening browser for authentication to get your SCREENCI_SECRET...');
|
|
1205
1303
|
const appUrl = getDevFrontendUrl();
|
|
1206
1304
|
try {
|
|
1207
|
-
const secret = await performBrowserLogin(appUrl
|
|
1305
|
+
const secret = await performBrowserLogin(appUrl, {
|
|
1306
|
+
openBrowserImmediately: true,
|
|
1307
|
+
});
|
|
1208
1308
|
process.env.SCREENCI_SECRET = secret;
|
|
1209
1309
|
const savePath = resolvedConfigPath
|
|
1210
|
-
?
|
|
1211
|
-
resolve(process.cwd(), '.env'))
|
|
1310
|
+
? await resolveProjectEnvFilePath(resolvedConfigPath)
|
|
1212
1311
|
: resolve(process.cwd(), '.env');
|
|
1213
|
-
await
|
|
1312
|
+
await persistScreenCISecret(savePath, secret);
|
|
1214
1313
|
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1215
1314
|
return secret;
|
|
1216
1315
|
}
|
|
1217
1316
|
catch (err) {
|
|
1218
1317
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1219
1318
|
logger.warn(`Authentication failed: ${msg}`);
|
|
1220
|
-
logger.info(
|
|
1319
|
+
logger.info(`You can add SCREENCI_SECRET manually to .env later. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1320
|
+
logScreenCISecretGuide();
|
|
1221
1321
|
return undefined;
|
|
1222
1322
|
}
|
|
1223
1323
|
}
|
|
1324
|
+
async function runLogin(configPath, open = false) {
|
|
1325
|
+
const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
|
|
1326
|
+
if (process.env.SCREENCI_SECRET) {
|
|
1327
|
+
logger.info('SCREENCI_SECRET is already configured.');
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const savePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
1331
|
+
const appUrl = getDevFrontendUrl();
|
|
1332
|
+
try {
|
|
1333
|
+
const secret = await performBrowserLogin(appUrl, {
|
|
1334
|
+
openBrowserImmediately: open,
|
|
1335
|
+
});
|
|
1336
|
+
process.env.SCREENCI_SECRET = secret;
|
|
1337
|
+
await persistScreenCISecret(savePath, secret);
|
|
1338
|
+
logger.info(`Successfully saved SCREENCI_SECRET to ${savePath}`);
|
|
1339
|
+
}
|
|
1340
|
+
catch (err) {
|
|
1341
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1342
|
+
logger.warn(`Authentication failed: ${msg}`);
|
|
1343
|
+
logger.info(`You can run ${pc.cyan('screenci login')} again or add SCREENCI_SECRET manually to ${savePath}. Get it from ${SCREENCI_SECRETS_URL}.`);
|
|
1344
|
+
logScreenCISecretGuide();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1224
1347
|
export async function main() {
|
|
1225
1348
|
if (process.argv.length <= 2) {
|
|
1226
1349
|
logger.error('Error: No command provided');
|
|
1227
|
-
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
1350
|
+
logger.error('Available commands: login, record, test, info, make-public, make-private, init');
|
|
1228
1351
|
process.exit(1);
|
|
1229
1352
|
}
|
|
1230
1353
|
const program = new Command();
|
|
1231
1354
|
const defaultPackageManager = determinePackageManager();
|
|
1232
1355
|
program.name('screenci');
|
|
1233
1356
|
program.exitOverride();
|
|
1357
|
+
program
|
|
1358
|
+
.command('login')
|
|
1359
|
+
.description('Authenticate and save SCREENCI_SECRET for this project')
|
|
1360
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
1361
|
+
.option('--open', 'open the login URL in your browser immediately')
|
|
1362
|
+
.action(async (options) => {
|
|
1363
|
+
await runLogin(options.config, options.open === true);
|
|
1364
|
+
});
|
|
1234
1365
|
// record command — playwright args pass through as-is
|
|
1235
1366
|
program
|
|
1236
1367
|
.command('record [playwrightArgs...]')
|
|
@@ -1260,10 +1391,9 @@ export async function main() {
|
|
|
1260
1391
|
if (resolvedConfigPath) {
|
|
1261
1392
|
try {
|
|
1262
1393
|
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1394
|
+
loadEnvFile(screenciConfig.envFile
|
|
1395
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
1396
|
+
: resolve(dirname(resolvedConfigPath), '.env'), true);
|
|
1267
1397
|
const apiUrl = getDevBackendUrl();
|
|
1268
1398
|
const appUrl = getDevFrontendUrl();
|
|
1269
1399
|
const secret = process.env.SCREENCI_SECRET;
|
|
@@ -1275,7 +1405,7 @@ export async function main() {
|
|
|
1275
1405
|
logger.info('All recordings failed.');
|
|
1276
1406
|
}
|
|
1277
1407
|
else if (!secret) {
|
|
1278
|
-
logger.info('No
|
|
1408
|
+
logger.info('No SCREENCI_SECRET configured for uploads. Run screenci login or add it to the project env file.');
|
|
1279
1409
|
}
|
|
1280
1410
|
else if (playwrightFailure !== null &&
|
|
1281
1411
|
uploadPolicy === 'all-or-nothing') {
|
|
@@ -1530,7 +1660,9 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1530
1660
|
}
|
|
1531
1661
|
// Only validate args for record command
|
|
1532
1662
|
if (command === 'record') {
|
|
1533
|
-
|
|
1663
|
+
if (!process.env.SCREENCI_SECRET) {
|
|
1664
|
+
await requireScreenCISecret(configPath);
|
|
1665
|
+
}
|
|
1534
1666
|
validateArgs(additionalArgs);
|
|
1535
1667
|
const screenciDir = resolve(dirname(configPath), '.screenci');
|
|
1536
1668
|
clearDirectory(screenciDir);
|
|
@@ -1538,6 +1670,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1538
1670
|
const envForChild = { ...process.env };
|
|
1539
1671
|
await validateUniqueDiscoveredTestTitles(configPath, additionalArgs, {
|
|
1540
1672
|
...envForChild,
|
|
1673
|
+
SCREENCI_CONFIG_DIR: dirname(configPath),
|
|
1541
1674
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1542
1675
|
...(command === 'test' && !mockRecord
|
|
1543
1676
|
? { [SCREENCI_DISABLE_RECORDING_TIMINGS_ENV]: 'true' }
|
|
@@ -1562,6 +1695,7 @@ async function run(command, additionalArgs, customConfigPath, verbose = false, m
|
|
|
1562
1695
|
: {}),
|
|
1563
1696
|
env: {
|
|
1564
1697
|
...envForChild,
|
|
1698
|
+
SCREENCI_CONFIG_DIR: dirname(configPath),
|
|
1565
1699
|
// Enable recording only for record command
|
|
1566
1700
|
...(command === 'record' ? { SCREENCI_RECORDING: 'true' } : {}),
|
|
1567
1701
|
...(command === 'test' && !mockRecord
|