mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
CLI docker/podman options added, pre-commit added
This commit is contained in:
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
npm exec lint-staged
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
test-results/
|
||||
package-lock.json
|
||||
+191
-6
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
Generated
+3085
-6
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -1178,7 +1178,6 @@ export function instrumentLocator(locator: Locator): Locator {
|
||||
dragEasing = 'ease-in-out',
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
...dragOpts
|
||||
} = options ?? {}
|
||||
|
||||
const page = locator.page()
|
||||
|
||||
+6
-3
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user