CLI docker/podman options added, pre-commit added

This commit is contained in:
Olli Paloviita
2026-04-01 14:28:07 +03:00
parent 544b6cdf4a
commit 91ddccfb44
14 changed files with 3723 additions and 145 deletions
+36
View File
@@ -0,0 +1,36 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linting
run: npm run lint
- name: Run type checking
run: npm run type-check
- name: Run unit tests
run: npm run test:run
+1
View File
@@ -0,0 +1 @@
npm exec lint-staged
+8
View File
@@ -0,0 +1,8 @@
export default {
'*.{js,ts,mjs}': ['prettier --write', 'eslint --fix'],
'*.{ts}': (filenames) => [
'npm run type-check',
`npm exec vitest related ${filenames.join(' ')} --run`,
],
'*.{json,md,yml,yaml,html}': ['prettier --write'],
}
+4
View File
@@ -0,0 +1,4 @@
dist/
node_modules/
test-results/
package-lock.json
+191 -6
View File
@@ -14,7 +14,6 @@ const mockReaddirSync = vi.fn(() => [] as string[])
const mockReaddir = vi.fn()
const mockReadFile = vi.fn()
const mockStat = vi.fn()
const mockFetch = vi.fn()
const mockCreateReadStream = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
@@ -80,6 +79,7 @@ describe('CLI', () => {
let mockChildProcess: EventEmitter
let loggerErrorSpy: ReturnType<typeof vi.spyOn>
let loggerInfoSpy: ReturnType<typeof vi.spyOn>
let loggerWarnSpy: ReturnType<typeof vi.spyOn>
let processExitSpy: ReturnType<typeof vi.spyOn>
let originalArgv: string[]
let originalEnv: NodeJS.ProcessEnv
@@ -123,6 +123,7 @@ describe('CLI', () => {
// Mock logger methods
loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {})
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {})
loggerWarnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {})
// Mock process.exit
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
@@ -138,6 +139,7 @@ describe('CLI', () => {
// Restore spies
loggerErrorSpy?.mockRestore()
loggerInfoSpy?.mockRestore()
loggerWarnSpy?.mockRestore()
processExitSpy?.mockRestore()
})
@@ -327,7 +329,9 @@ describe('CLI', () => {
const { main } = await import('./cli')
const mainPromise = main()
await new Promise((resolve) => setTimeout(resolve, 10))
await vi.waitFor(() => {
expect(mockSpawn).toHaveBeenCalled()
})
mockChildProcess.emit('close', 0)
await mainPromise
@@ -530,6 +534,38 @@ describe('CLI', () => {
)
})
it('should use docker when --docker is provided', async () => {
process.argv = ['node', 'cli.js', 'record', '--docker']
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'Docker version 28.0.1, build abc123',
stderr: '',
})
const { main } = await import('./cli')
const mainPromise = main()
await new Promise((resolve) => setTimeout(resolve, 10))
mockBuildProcess.emit('close', 0)
await new Promise((resolve) => setTimeout(resolve, 10))
mockRecordingBuildProcess.emit('close', 0)
await new Promise((resolve) => setTimeout(resolve, 10))
mockRunProcess.emit('close', 0)
await mainPromise
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'docker',
expect.arrayContaining(['build']),
expect.objectContaining({ stdio: 'pipe' })
)
expect(mockSpawnSync).toHaveBeenCalledTimes(1)
})
it('should clear and recreate .screenci directory before running container', async () => {
process.argv = ['node', 'cli.js', 'record']
@@ -710,20 +746,30 @@ describe('CLI', () => {
describe('detectContainerRuntime', () => {
it('should return podman when available', async () => {
mockSpawnSync.mockReturnValue({ status: 0, error: undefined })
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 5.2.0',
stderr: '',
})
const { detectContainerRuntime } = await import('./cli')
expect(detectContainerRuntime()).toBe('podman')
expect(mockSpawnSync).toHaveBeenCalledWith('podman', ['--version'], {
stdio: 'ignore',
encoding: 'utf8',
})
})
it('should return docker when podman is not available', async () => {
mockSpawnSync
.mockReturnValueOnce({ status: 1, error: undefined }) // podman fails
.mockReturnValueOnce({ status: 0, error: undefined }) // docker succeeds
.mockReturnValueOnce({
status: 0,
error: undefined,
stdout: 'Docker version 28.0.1, build abc123',
stderr: '',
}) // docker succeeds
const { detectContainerRuntime } = await import('./cli')
@@ -731,11 +777,17 @@ describe('CLI', () => {
})
it('should prefer podman over docker when both are available', async () => {
mockSpawnSync.mockReturnValue({ status: 0, error: undefined })
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 5.2.0',
stderr: '',
})
const { detectContainerRuntime } = await import('./cli')
expect(detectContainerRuntime()).toBe('podman')
expect(mockSpawnSync).toHaveBeenCalledTimes(1)
})
it('should exit when neither podman nor docker is available', async () => {
@@ -747,6 +799,11 @@ describe('CLI', () => {
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Neither podman nor docker found')
)
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'https://screenci.com/guides/getting-started/#prerequisites'
)
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
@@ -761,6 +818,66 @@ describe('CLI', () => {
expect(() => detectContainerRuntime()).toThrow('process.exit called')
expect(processExitSpy).toHaveBeenCalledWith(1)
})
it('should warn when preferred podman version is below the recommendation', async () => {
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 4.9.3',
stderr: '',
})
const { detectContainerRuntime } = await import('./cli')
expect(detectContainerRuntime()).toBe('podman')
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('podman 5+ recommended')
)
})
it('should not warn about docker when podman is available and up to date', async () => {
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 5.2.0',
stderr: '',
})
const { detectContainerRuntime } = await import('./cli')
expect(detectContainerRuntime()).toBe('podman')
expect(loggerWarnSpy).not.toHaveBeenCalled()
expect(mockSpawnSync).toHaveBeenCalledTimes(1)
})
it('should return forced docker when docker is available', async () => {
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'Docker version 28.0.1, build abc123',
stderr: '',
})
const { detectContainerRuntime } = await import('./cli')
expect(detectContainerRuntime('docker')).toBe('docker')
expect(mockSpawnSync).toHaveBeenCalledTimes(1)
expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], {
encoding: 'utf8',
})
})
it('should exit when forced podman is unavailable', async () => {
mockSpawnSync.mockReturnValue({ status: 1, error: undefined })
const { detectContainerRuntime } = await import('./cli')
expect(() => detectContainerRuntime('podman')).toThrow(
'process.exit called'
)
expect(loggerErrorSpy).toHaveBeenCalledWith('Error: podman not found.')
expect(processExitSpy).toHaveBeenCalledWith(1)
})
})
describe('error handling', () => {
@@ -849,6 +966,19 @@ describe('CLI', () => {
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
it('should exit if both --podman and --docker are provided', async () => {
process.argv = ['node', 'cli.js', 'record', '--podman', '--docker']
const { main } = await import('./cli')
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).toHaveBeenCalledWith(
'Error: --podman and --docker cannot be used together'
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
})
describe('logging', () => {
@@ -1422,6 +1552,61 @@ describe('CLI', () => {
expect(mockCreateHttpServer).not.toHaveBeenCalled()
})
it('should warn during init when neither podman nor docker is installed', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
mockSpawnSync
.mockReturnValueOnce({ status: 1, error: undefined })
.mockReturnValueOnce({ status: 1, error: undefined })
const { main } = await import('./cli')
await main()
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Neither podman nor docker found')
)
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'https://screenci.com/guides/getting-started/#prerequisites'
)
)
})
it('should warn during init when podman is present but below version 5', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 4.9.3',
stderr: '',
})
const { main } = await import('./cli')
await main()
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('podman 5+ recommended')
)
})
it('should not warn during init when podman is available and supported', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
mockSpawnSync.mockReturnValue({
status: 0,
error: undefined,
stdout: 'podman version 5.2.0',
stderr: '',
})
const { main } = await import('./cli')
await main()
expect(loggerWarnSpy).not.toHaveBeenCalled()
expect(mockSpawnSync).toHaveBeenCalledTimes(1)
})
it('should automatically run npm install', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
+316 -106
View File
@@ -64,9 +64,11 @@ function contentTypeForPath(filePath: string): string {
type CustomVoiceRefLike = { id: string; path: string }
type PreparedCustomVoiceAsset = {
id: string
type PreparedUploadAsset = {
fileHash: string
path: string
size: number
name?: string
fileBuffer?: Buffer
contentType?: string
}
@@ -94,6 +96,81 @@ function parseDockerfileVersion(dockerfilePath: string): string {
return match?.[1] ?? 'unknown'
}
const CONTAINER_RUNTIME_DOCS_URL =
'https://screenci.com/guides/getting-started/#prerequisites'
const MIN_CONTAINER_RUNTIME_MAJOR_VERSION = {
podman: 5,
docker: 28,
} as const
type ContainerRuntimeName = keyof typeof MIN_CONTAINER_RUNTIME_MAJOR_VERSION
type ContainerRuntimeCheckResult = {
runtime: ContainerRuntimeName
version: string
majorVersion: number | null
}
function parseContainerRuntimeMajorVersion(
versionOutput: string
): number | null {
const match = versionOutput.match(/(\d+)(?:\.\d+){0,2}/)
if (!match) return null
const majorVersion = Number.parseInt(match[1] ?? '', 10)
return Number.isNaN(majorVersion) ? null : majorVersion
}
function checkContainerRuntime(
runtime: ContainerRuntimeName
): ContainerRuntimeCheckResult | null {
const result = spawnSync(runtime, ['--version'], { encoding: 'utf8' })
if (result.status !== 0 || result.error !== undefined) {
return null
}
const version = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
return {
runtime,
version,
majorVersion: parseContainerRuntimeMajorVersion(version),
}
}
function getPreferredContainerRuntime(): ContainerRuntimeCheckResult | null {
const podman = checkContainerRuntime('podman')
if (podman) return podman
return checkContainerRuntime('docker')
}
function exitContainerRuntimeNotFound(runtime: ContainerRuntimeName): never {
logger.error(`Error: ${runtime} not found.`)
logger.error(`Install ${runtime} or remove the --${runtime} flag.`)
logger.error(`See prerequisites: ${CONTAINER_RUNTIME_DOCS_URL}`)
process.exit(1)
}
function warnIfContainerRuntimeVersionIsOld(
runtimeCheck: ContainerRuntimeCheckResult
): void {
const minimumVersion =
MIN_CONTAINER_RUNTIME_MAJOR_VERSION[runtimeCheck.runtime]
if (
runtimeCheck.majorVersion !== null &&
runtimeCheck.majorVersion < minimumVersion
) {
logger.warn(
`Warning: ${runtimeCheck.runtime} ${minimumVersion}+ recommended (detected: ${runtimeCheck.version})`
)
logger.warn(`See prerequisites: ${CONTAINER_RUNTIME_DOCS_URL}`)
}
}
function spawnSilent(cmd: string, args: string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { stdio: 'pipe' })
@@ -203,6 +280,7 @@ function parseArgs(args: string[]): {
noContainer: boolean
imageTag: string | undefined
verbose: boolean
forcedRuntime: ContainerRuntimeName | undefined
otherArgs: string[]
} {
const command = args[0]
@@ -215,6 +293,7 @@ function parseArgs(args: string[]): {
let noContainer = false
let imageTag: string | undefined
let verbose = false
let forcedRuntime: ContainerRuntimeName | undefined
const otherArgs: string[] = []
for (let i = 1; i < args.length; i++) {
@@ -232,6 +311,18 @@ function parseArgs(args: string[]): {
noContainer = true
} else if (arg === '--verbose' || arg === '-v') {
verbose = true
} else if (arg === '--podman') {
if (forcedRuntime === 'docker') {
logger.error('Error: --podman and --docker cannot be used together')
process.exit(1)
}
forcedRuntime = 'podman'
} else if (arg === '--docker') {
if (forcedRuntime === 'podman') {
logger.error('Error: --podman and --docker cannot be used together')
process.exit(1)
}
forcedRuntime = 'docker'
} else if (arg === '--tag') {
const nextArg = args[i + 1]
if (nextArg !== undefined) {
@@ -246,7 +337,15 @@ function parseArgs(args: string[]): {
}
}
return { command, configPath, noContainer, imageTag, verbose, otherArgs }
return {
command,
configPath,
noContainer,
imageTag,
verbose,
forcedRuntime,
otherArgs,
}
}
async function findLatestEntry(screenciDir: string): Promise<string | null> {
@@ -276,71 +375,10 @@ async function findLatestEntry(screenciDir: string): Promise<string | null> {
return latestEntry
}
async function uploadAssets(
data: RecordingData,
apiUrl: string,
secret: string,
recordingId: string,
configDir: string
): Promise<void> {
type AssetStartEvent = Extract<
RecordingData['events'][number],
{ type: 'assetStart' }
>
const assetEvents = (data.events as RecordingData['events']).filter(
(e): e is AssetStartEvent => e.type === 'assetStart'
)
if (assetEvents.length === 0) return
// Deduplicate by name — each unique asset name is uploaded once
const seenNames = new Set<string>()
for (const event of assetEvents) {
const assetPath = event.path
if (seenNames.has(event.name)) continue
seenNames.add(event.name)
const resolvedFile = await readRecordingFile(assetPath, configDir)
if (resolvedFile === null) {
logger.warn(`Asset file not found, skipping upload: ${assetPath}`)
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
const sha256 = createHash('sha256').update(fileBuffer).digest('hex')
const contentType = contentTypeForPath(resolvedPath)
try {
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({
sha256,
fileBase64: fileBuffer.toString('base64'),
contentType,
assetName: event.name,
}),
})
if (!res.ok) {
const text = await res.text()
logger.warn(
`Failed to upload asset ${assetPath}: ${res.status} ${text}`
)
} else {
logger.info(`Asset uploaded: ${assetPath}`)
}
} catch (err) {
logger.warn(`Network error uploading asset ${assetPath}:`, err)
}
}
}
async function prepareCustomVoiceAssets(
data: RecordingData,
configDir: string
): Promise<PreparedCustomVoiceAsset[]> {
): Promise<PreparedUploadAsset[]> {
const customVoiceRefsByPath = new Map<string, CustomVoiceRefLike[]>()
for (const event of data.events) {
@@ -376,7 +414,7 @@ async function prepareCustomVoiceAssets(
}
}
const preparedAssets: PreparedCustomVoiceAsset[] = []
const preparedAssets: PreparedUploadAsset[] = []
for (const [voicePath, refs] of customVoiceRefsByPath) {
const resolvedFile = await readRecordingFile(voicePath, configDir)
@@ -393,7 +431,12 @@ async function prepareCustomVoiceAssets(
for (const ref of refs) {
ref.id = existingId
}
preparedAssets.push({ id: existingId, path: voicePath })
preparedAssets.push({
fileHash: existingId,
path: voicePath,
size: 0,
contentType: contentTypeForPath(voicePath),
})
continue
}
@@ -403,21 +446,157 @@ async function prepareCustomVoiceAssets(
for (const ref of refs) {
ref.id = id
}
preparedAssets.push({ id, path: voicePath, fileBuffer, contentType })
preparedAssets.push({
fileHash: id,
path: voicePath,
size: fileBuffer.byteLength,
fileBuffer,
contentType,
})
}
return preparedAssets
}
async function uploadCustomVoiceAssets(
assets: PreparedCustomVoiceAsset[],
async function collectUploadAssets(
data: RecordingData,
configDir: string
): Promise<PreparedUploadAsset[]> {
const assets = new Map<string, PreparedUploadAsset>()
for (const event of data.events) {
if (event.type === 'assetStart') {
if (assets.has(`name:${event.name}`)) continue
const resolvedFile = await readRecordingFile(event.path, configDir)
if (resolvedFile === null) {
logger.warn(`Asset file not found, skipping upload: ${event.path}`)
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
assets.set(`name:${event.name}`, {
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
path: event.path,
name: event.name,
size: fileBuffer.byteLength,
fileBuffer,
contentType: contentTypeForPath(resolvedPath),
})
continue
}
if (event.type === 'videoCaptionStart') {
if (typeof event.assetPath === 'string') {
const key = `path:${event.assetPath}`
if (!assets.has(key)) {
const resolvedFile = await readRecordingFile(
event.assetPath,
configDir
)
if (resolvedFile === null) {
logger.warn(
`Video caption asset file not found, skipping upload: ${event.assetPath}`
)
} else {
const { buffer: fileBuffer, resolvedPath } = resolvedFile
assets.set(key, {
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
path: event.assetPath,
size: fileBuffer.byteLength,
fileBuffer,
contentType: contentTypeForPath(resolvedPath),
})
}
}
}
if (event.translations) {
for (const translation of Object.values(event.translations)) {
if (
typeof translation === 'object' &&
translation !== null &&
'assetPath' in translation &&
typeof translation.assetPath === 'string'
) {
const key = `path:${translation.assetPath}`
if (assets.has(key)) continue
const resolvedFile = await readRecordingFile(
translation.assetPath,
configDir
)
if (resolvedFile === null) {
logger.warn(
`Video caption asset file not found, skipping upload: ${translation.assetPath}`
)
continue
}
const { buffer: fileBuffer, resolvedPath } = resolvedFile
assets.set(key, {
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
path: translation.assetPath,
size: fileBuffer.byteLength,
fileBuffer,
contentType: contentTypeForPath(resolvedPath),
})
}
}
}
}
}
for (const asset of await prepareCustomVoiceAssets(data, configDir)) {
assets.set(`path:${asset.path}`, asset)
}
return [...assets.values()]
}
async function uploadAssets(
assets: PreparedUploadAsset[],
apiUrl: string,
secret: string,
recordingId: string
): Promise<void> {
for (const asset of assets) {
if (!asset.fileBuffer || !asset.contentType) continue
try {
const checkRes = await fetch(
`${apiUrl}/cli/upload/${recordingId}/asset/check`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({
fileHash: asset.fileHash,
contentType: asset.contentType,
size: asset.size,
path: asset.path,
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
}),
}
)
if (!checkRes.ok) {
const text = await checkRes.text()
logger.warn(
`Failed to check asset ${asset.path}: ${checkRes.status} ${text}`
)
continue
}
const checkBody = (await checkRes.json()) as { exists: boolean }
if (checkBody.exists) {
logger.info(`Asset already exists: ${asset.path}`)
continue
}
if (!asset.fileBuffer || !asset.contentType) {
logger.warn(
`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`
)
continue
}
const res = await fetch(`${apiUrl}/cli/upload/${recordingId}/asset`, {
method: 'PUT',
headers: {
@@ -425,22 +604,28 @@ async function uploadCustomVoiceAssets(
'X-ScreenCI-Secret': secret,
},
body: JSON.stringify({
sha256: asset.id,
fileHash: asset.fileHash,
fileBase64: asset.fileBuffer.toString('base64'),
contentType: asset.contentType,
assetName: asset.id,
size: asset.size,
path: asset.path,
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
}),
})
if (!res.ok) {
const text = await res.text()
logger.warn(
`Failed to upload custom voice ${asset.path}: ${res.status} ${text}`
)
if (res.status === 409 && text.includes('already exists')) {
logger.info(`Asset already exists: ${asset.path}`)
} else {
logger.warn(
`Failed to upload asset ${asset.path}: ${res.status} ${text}`
)
}
} else {
logger.info(`Custom voice uploaded: ${asset.path}`)
logger.info(`Asset uploaded: ${asset.path}`)
}
} catch (err) {
logger.warn(`Network error uploading custom voice ${asset.path}:`, err)
logger.warn(`Network error uploading asset ${asset.path}:`, err)
}
}
}
@@ -480,7 +665,7 @@ async function uploadRecordings(
}
const videoName = data.metadata?.videoName ?? entry
const preparedCustomVoiceAssets = await prepareCustomVoiceAssets(
const preparedUploadAssets = await collectUploadAssets(
data,
resolve(screenciDir, '..')
)
@@ -513,20 +698,8 @@ async function uploadRecordings(
firstProjectId = projectId
}
// Step 1b: upload asset files referenced in data.json
await uploadAssets(
data,
apiUrl,
secret,
recordingId,
resolve(screenciDir, '..')
)
await uploadCustomVoiceAssets(
preparedCustomVoiceAssets,
apiUrl,
secret,
recordingId
)
// Step 1b: upload all referenced files via the shared asset flow
await uploadAssets(preparedUploadAssets, apiUrl, secret, recordingId)
// Step 2: stream the recording video file (if it exists)
const recordingPath = resolve(screenciDir, entry, 'recording.mp4')
@@ -896,6 +1069,7 @@ async function runInit(
localPackagePath?: string
): Promise<void> {
checkNodeVersion()
checkContainerRuntimeForInit()
let projectName = projectNameArg?.trim()
@@ -958,8 +1132,15 @@ async function runInit(
export async function main() {
const args = process.argv.slice(2)
const { command, configPath, noContainer, imageTag, verbose, otherArgs } =
parseArgs(args)
const {
command,
configPath,
noContainer,
imageTag,
verbose,
forcedRuntime,
otherArgs,
} = parseArgs(args)
switch (command) {
case 'record': {
@@ -1017,7 +1198,13 @@ export async function main() {
}
if (useContainer) {
await runWithContainer(otherArgs, configPath, imageTag, verbose)
await runWithContainer(
otherArgs,
configPath,
imageTag,
verbose,
forcedRuntime
)
} else {
await run(command, otherArgs, configPath)
}
@@ -1169,22 +1356,44 @@ function spawnInherited(cmd: string, args: string[]): Promise<void> {
})
}
export function detectContainerRuntime(): string {
for (const runtime of ['podman', 'docker']) {
const result = spawnSync(runtime, ['--version'], { stdio: 'ignore' })
if (result.status === 0 && result.error === undefined) {
return runtime
}
export function detectContainerRuntime(
forcedRuntime?: ContainerRuntimeName
): string {
const runtimeCheck = forcedRuntime
? checkContainerRuntime(forcedRuntime)
: getPreferredContainerRuntime()
if (runtimeCheck) {
warnIfContainerRuntimeVersionIsOld(runtimeCheck)
return runtimeCheck.runtime
}
if (forcedRuntime) {
exitContainerRuntimeNotFound(forcedRuntime)
}
logger.error('Error: Neither podman nor docker found.')
logger.error(
'Please install podman (recommended) or docker to use screenci record.'
)
logger.error(' podman: https://podman.io/docs/installation')
logger.error(' docker: https://docs.docker.com/get-docker/')
logger.error(`See prerequisites: ${CONTAINER_RUNTIME_DOCS_URL}`)
process.exit(1)
}
function checkContainerRuntimeForInit(): void {
const runtimeCheck = getPreferredContainerRuntime()
if (!runtimeCheck) {
logger.warn(
'Warning: Neither podman nor docker found. Install one before running screenci record.'
)
logger.warn(`See prerequisites: ${CONTAINER_RUNTIME_DOCS_URL}`)
return
}
warnIfContainerRuntimeVersionIsOld(runtimeCheck)
}
async function buildImage(
cmd: string,
args: string[],
@@ -1212,7 +1421,8 @@ async function runWithContainer(
additionalArgs: string[],
customConfigPath?: string,
imageTag?: string,
verbose = false
verbose = false,
forcedRuntime?: ContainerRuntimeName
) {
const configPath = findScreenCIConfig(customConfigPath)
@@ -1243,7 +1453,7 @@ async function runWithContainer(
process.exit(1)
}
const containerRuntime = detectContainerRuntime()
const containerRuntime = detectContainerRuntime(forcedRuntime)
const ghcrImage = 'ghcr.io/screenci/record:latest'
+16 -16
View File
@@ -41,19 +41,19 @@ docker --version # alternatively: use this if Podman is missing; Docker 28+ reco
## Contents
| Doc | Description |
| --------------------------------------- | ------------------------------------------------------------- |
| [Introduction](./intro.md) | Overview of ScreenCI and where to start |
| [Getting Started](./getting-started.md) | Install screenci, scaffold a project, and record your first video |
| [Recording Flows](./recording.md) | Write polished product video scripts with captions and zoom |
| [Deployment Automation](./automation.md) | Automate rendering and updates in CI/CD |
| [Editing by Typing](./editing.md) | Update scripts and narration without full reshoots |
| [AI-Supported Editing](./ai-editing.md) | AI-facing docs access, llms.txt, and MCP workflows |
| [Localization & Voiceovers](./localization.md) | Multi-language narration and localized UI videos |
| [Prerequisites: macOS](./prerequisites-mac.md) | macOS setup steps for Node.js and Podman |
| [Prerequisites: Windows](./prerequisites-win.md) | Windows setup steps for Node.js and Podman |
| [Prerequisites: Linux](./prerequisites-linux.md) | Linux setup steps for Node.js and Podman |
| [Configuration](./configuration.md) | `defineConfig` options, per-test overrides, defaults |
| [Writing Video Tests](./video-tests.md) | How to use `video()`, `caption()`, multiple tests, auth, etc. |
| [API Reference](./api.md) | Full reference for all exported functions and types |
| [Public API](./public-api.md) | Public endpoints for published videos and subtitles |
| Doc | Description |
| ------------------------------------------------ | ----------------------------------------------------------------- |
| [Introduction](./intro.md) | Overview of ScreenCI and where to start |
| [Getting Started](./getting-started.md) | Install screenci, scaffold a project, and record your first video |
| [Recording Flows](./recording.md) | Write polished product video scripts with captions and zoom |
| [Deployment Automation](./automation.md) | Automate rendering and updates in CI/CD |
| [Editing by Typing](./editing.md) | Update scripts and narration without full reshoots |
| [AI-Supported Editing](./ai-editing.md) | AI-facing docs access, llms.txt, and MCP workflows |
| [Localization & Voiceovers](./localization.md) | Multi-language narration and localized UI videos |
| [Prerequisites: macOS](./prerequisites-mac.md) | macOS setup steps for Node.js and Podman |
| [Prerequisites: Windows](./prerequisites-win.md) | Windows setup steps for Node.js and Podman |
| [Prerequisites: Linux](./prerequisites-linux.md) | Linux setup steps for Node.js and Podman |
| [Configuration](./configuration.md) | `defineConfig` options, per-test overrides, defaults |
| [Writing Video Tests](./video-tests.md) | How to use `video()`, `caption()`, multiple tests, auth, etc. |
| [API Reference](./api.md) | Full reference for all exported functions and types |
| [Public API](./public-api.md) | Public endpoints for published videos and subtitles |
+34
View File
@@ -0,0 +1,34 @@
import tseslint from 'typescript-eslint'
import unusedImports from 'eslint-plugin-unused-imports'
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/playwright-report/**',
'**/test-results/**',
],
},
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
plugins: {
'unused-imports': unusedImports,
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
]
+3085 -6
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -32,17 +32,31 @@
"build": "tsc",
"postbuild": "cp Dockerfile dist/",
"build:image": "podman build -t screenci .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"type-check": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"prepare": "husky",
"release": "npm run test:run && npm publish"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "25.0.3",
"eslint": "^9.39.2",
"eslint-plugin-unused-imports": "^4.3.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"tsx": "^4.21.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"typescript-eslint": "^8.50.0",
"vitest": "^4.0.16"
},
"peerDependencies": {
"@playwright/test": ">=1.57.0"
+10
View File
@@ -0,0 +1,10 @@
/** @type {import("prettier").Config} */
const config = {
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
printWidth: 80,
}
export default config
+1 -6
View File
@@ -5,12 +5,7 @@ import type {
VideoCaptionTranslation,
VideoCaptionTranslationFile,
} from './events.js'
import type {
VoiceKey,
VoiceForLang,
Lang,
CustomVoiceRef,
} from './voices.js'
import type { VoiceKey, VoiceForLang, Lang, CustomVoiceRef } from './voices.js'
import { isCustomVoiceRef } from './voices.js'
import { isInsideHide } from './hide.js'
import { access, readFile } from 'fs/promises'
-1
View File
@@ -1178,7 +1178,6 @@ export function instrumentLocator(locator: Locator): Locator {
dragEasing = 'ease-in-out',
sourcePosition,
targetPosition,
...dragOpts
} = options ?? {}
const page = locator.page()
+6 -3
View File
@@ -50,8 +50,8 @@ import {
import { logger } from './logger.js'
async function setupMouseTracking(
page: Page,
recorder: EventRecorder
_page: Page,
_recorder: EventRecorder
): Promise<void> {
/*
await page.exposeFunction(
@@ -303,7 +303,10 @@ const _videoBase = base.extend<VideoFixtureOptions>({
// Internal worker fixture to manage xvfb per test
_xvfbSetup: [
async ({ recordOptions }, use: (arg: void) => Promise<void>) => {
async (
{ recordOptions: _recordOptions },
use: (arg: void) => Promise<void>
) => {
const shouldRecord = process.env.SCREENCI_RECORD === 'true'
if (shouldRecord && !currentXvfb && isHeadless()) {