Files
screenci__screenci/cli.auth.spec.ts
T
Olli Paloviita 9c4cfd284f Add screenci dev: connect this machine to the editor for on-demand records
New long-running command that registers a dev listener with the service,
short-polls for record triggers from the web editor, runs the requested
video and language through the normal record and upload path, and reports
status back. Authenticates with SCREENCI_SECRET plus a personal
SCREENCI_DEV_TOKEN so only the token owner's web session can trigger
records on this machine.

The previously removed dev command name is reintroduced on purpose; the
regression test now asserts the command exists instead.
2026-07-08 14:27:32 +03:00

462 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { ChildProcess } from 'child_process'
import { EventEmitter } from 'events'
import { Readable } from 'stream'
import { logger } from './src/logger.js'
const mockSpawn = vi.fn()
const mockExec = vi.fn()
const mockExistsSync = vi.fn()
const mockRealpathSync = vi.fn((path: string) => path)
const mockMkdirSync = vi.fn()
const mockRmSync = vi.fn()
const mockReaddirSync = vi.fn(() => [] as string[])
const mockReadFileSync = vi.fn()
const mockReaddir = vi.fn()
const mockReadFile = vi.fn()
const mockStat = vi.fn()
const mockCreateReadStream = vi.fn()
const mockAppendFile = vi.fn()
const mockWriteFile = vi.fn()
const mockMkdir = vi.fn()
const mockRm = vi.fn()
const mockInput = vi.fn()
const mockConfirm = vi.fn()
const mockCreateHttpServer = vi.fn()
const mockFetch = vi.fn()
const mockSpinner = {
start: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
stop: vi.fn().mockReturnThis(),
}
const mockOra = vi.fn().mockReturnValue(mockSpinner)
function expectNpmDevInstalls(
mockSpawn: ReturnType<typeof vi.fn>,
cwd: string,
screenciVersion = '0.0.32',
includePlaywrightCli = true
) {
const npmInstallCalls = mockSpawn.mock.calls.filter(
(call: unknown[]) =>
call[0] === 'npm' &&
Array.isArray(call[1]) &&
call[1][0] === 'install' &&
call[1][1] === '--save-dev' &&
call[2] &&
typeof call[2] === 'object' &&
'cwd' in (call[2] as Record<string, unknown>) &&
(call[2] as { cwd?: string }).cwd === cwd &&
'stdio' in (call[2] as Record<string, unknown>) &&
(call[2] as { stdio?: string }).stdio === 'pipe'
)
const expectedPackages = [
`@playwright/test@^1.59.0`,
`screenci@${screenciVersion}`,
'@types/node@^25.9.1',
...(includePlaywrightCli ? ['@playwright/cli@latest'] : []),
]
expect(npmInstallCalls).toEqual(
expect.arrayContaining(
expectedPackages.map((pkg) => [
'npm',
['install', '--save-dev', pkg],
expect.objectContaining({ cwd, stdio: 'pipe' }),
])
)
)
}
function expectPnpmDevInstalls(
mockSpawn: ReturnType<typeof vi.fn>,
cwd: string,
screenciVersion = '0.0.32',
includePlaywrightCli = true
) {
const pnpmInstallCalls = mockSpawn.mock.calls.filter(
(call: unknown[]) =>
call[0] === 'pnpm' &&
Array.isArray(call[1]) &&
call[1][0] === 'add' &&
call[1][1] === '--save-dev' &&
call[2] &&
typeof call[2] === 'object' &&
'cwd' in (call[2] as Record<string, unknown>) &&
(call[2] as { cwd?: string }).cwd === cwd &&
'stdio' in (call[2] as Record<string, unknown>) &&
(call[2] as { stdio?: string }).stdio === 'pipe'
)
const expectedPackages = [
['add', '--save-dev', `@playwright/test@^1.59.0`],
[
'add',
'--save-dev',
'--allow-build=ffmpeg-static',
`screenci@${screenciVersion}`,
],
['add', '--save-dev', '@types/node@^25.9.1'],
...(includePlaywrightCli
? [['add', '--save-dev', '@playwright/cli@latest']]
: []),
]
expect(pnpmInstallCalls).toEqual(
expect.arrayContaining(
expectedPackages.map((args) => [
'pnpm',
args,
expect.objectContaining({ cwd, stdio: 'pipe' }),
])
)
)
}
function expectYarnDevInstalls(
mockSpawn: ReturnType<typeof vi.fn>,
cwd: string,
screenciVersion = '0.0.32',
includePlaywrightCli = true
) {
const yarnInstallCalls = mockSpawn.mock.calls.filter(
(call: unknown[]) =>
call[0] === 'yarn' &&
Array.isArray(call[1]) &&
call[1][0] === 'add' &&
call[1][1] === '--dev' &&
call[2] &&
typeof call[2] === 'object' &&
'cwd' in (call[2] as Record<string, unknown>) &&
(call[2] as { cwd?: string }).cwd === cwd &&
'stdio' in (call[2] as Record<string, unknown>) &&
(call[2] as { stdio?: string }).stdio === 'pipe'
)
const expectedPackages = [
['add', '--dev', `@playwright/test@^1.59.0`],
['add', '--dev', `screenci@${screenciVersion}`],
['add', '--dev', '@types/node@^25.9.1'],
...(includePlaywrightCli
? [['add', '--dev', '@playwright/cli@latest']]
: []),
]
expect(yarnInstallCalls).toEqual(
expect.arrayContaining(
expectedPackages.map((args) => [
'yarn',
args,
expect.objectContaining({ cwd, stdio: 'pipe' }),
])
)
)
}
vi.mock('child_process', () => ({
spawn: mockSpawn,
exec: mockExec,
createReadStream: mockCreateReadStream,
default: {
spawn: mockSpawn,
exec: mockExec,
createReadStream: mockCreateReadStream,
},
}))
vi.mock('fs', () => ({
createReadStream: mockCreateReadStream,
existsSync: mockExistsSync,
realpathSync: mockRealpathSync,
mkdirSync: mockMkdirSync,
rmSync: mockRmSync,
readdirSync: mockReaddirSync,
readFileSync: mockReadFileSync,
default: {
createReadStream: mockCreateReadStream,
existsSync: mockExistsSync,
realpathSync: mockRealpathSync,
mkdirSync: mockMkdirSync,
rmSync: mockRmSync,
readdirSync: mockReaddirSync,
readFileSync: mockReadFileSync,
},
}))
vi.mock('fs/promises', () => ({
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
writeFile: mockWriteFile,
mkdir: mockMkdir,
default: {
appendFile: mockAppendFile,
rm: mockRm,
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
writeFile: mockWriteFile,
mkdir: mockMkdir,
},
}))
vi.mock('@inquirer/prompts', () => ({
input: mockInput,
confirm: mockConfirm,
}))
vi.mock('ora', () => ({
default: mockOra,
}))
vi.mock('http', () => ({
createServer: mockCreateHttpServer,
default: { createServer: mockCreateHttpServer },
}))
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 loadEnvFileSpy: ReturnType<typeof vi.spyOn> | undefined
let originalArgv: string[]
let originalEnv: NodeJS.ProcessEnv
let originalFetch: typeof global.fetch
let originalLoadEnvFile: ((path: string | URL) => void) | undefined
beforeEach(() => {
// Reset all mocks (clearAllMocks only clears call history, not Once queues;
// mockReset also clears return values/implementations including Once queue)
vi.clearAllMocks()
mockSpawn.mockReset()
mockAppendFile.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockMkdir.mockResolvedValue(undefined)
mockRm.mockResolvedValue(undefined)
mockReaddir.mockResolvedValue([])
mockReadFileSync.mockImplementation(() => {
if (process.env.VITE_APP_BASE_URL === undefined) {
process.env.VITE_APP_BASE_URL = 'https://env-file.example.com'
}
return 'VITE_APP_BASE_URL=https://env-file.example.com\n'
})
mockReadFile.mockImplementation(async (path: string | URL) => {
if (String(path).endsWith('screenci.config.ts')) {
return "export default defineConfig({ projectName: 'Test Project' })"
}
if (String(path).endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
return ''
})
mockStat.mockResolvedValue({ size: 4 })
mockCreateReadStream.mockImplementation(() => {
const stream = new Readable({ read() {} })
process.nextTick(() => {
stream.push('data')
stream.push(null)
})
return stream
})
// Default inquirer responses
mockInput.mockImplementation(
async (options?: { default?: string }) => options?.default ?? ''
)
mockConfirm.mockResolvedValue(false)
// Restore ora mock return value after clearAllMocks
mockOra.mockReturnValue(mockSpinner)
mockSpinner.start.mockReturnThis()
mockSpinner.succeed.mockReturnThis()
mockSpinner.fail.mockReturnThis()
mockSpinner.stop.mockReturnThis()
// Store original values
originalArgv = process.argv
originalEnv = { ...process.env }
originalFetch = global.fetch
originalLoadEnvFile = (
process as NodeJS.Process & {
loadEnvFile?: (path: string | URL) => void
}
).loadEnvFile
delete process.env.npm_config_user_agent
// Mock child process (unref needed for openBrowser's detached spawn)
mockChildProcess = Object.assign(new EventEmitter(), {
unref: vi.fn(),
stdout: new EventEmitter(),
stderr: new EventEmitter(),
})
mockSpawn.mockReturnValue(mockChildProcess as unknown as ChildProcess)
// Mock file system
mockExistsSync.mockReturnValue(true)
// Default http server mock: does not resolve (login not triggered by default)
mockCreateHttpServer.mockReturnValue({
listen: vi.fn(),
close: vi.fn(),
address: vi.fn().mockReturnValue({ port: 12345 }),
on: vi.fn(),
})
// 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((() => {
throw new Error('process.exit called')
}) as unknown as (code?: string | number | null | undefined) => never)
if (typeof originalLoadEnvFile === 'function') {
loadEnvFileSpy = vi
.spyOn(
process as NodeJS.Process & {
loadEnvFile?: (path: string | URL) => void
},
'loadEnvFile'
)
.mockImplementation((path?: string | URL) => {
if (
String(path).endsWith('.env') &&
process.env.VITE_APP_BASE_URL === undefined
) {
process.env.VITE_APP_BASE_URL = 'https://env-file.example.com'
}
})
} else {
loadEnvFileSpy = undefined
;(
process as NodeJS.Process & {
loadEnvFile?: (path: string | URL) => void
}
).loadEnvFile = undefined
}
global.fetch = mockFetch as typeof global.fetch
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
})
})
afterEach(() => {
// Restore original values
process.argv = originalArgv
process.env = originalEnv
global.fetch = originalFetch
;(
process as NodeJS.Process & {
loadEnvFile?: (path: string | URL) => void
}
).loadEnvFile = originalLoadEnvFile
// Restore spies
loggerErrorSpy?.mockRestore()
loggerInfoSpy?.mockRestore()
loggerWarnSpy?.mockRestore()
processExitSpy?.mockRestore()
loadEnvFileSpy?.mockRestore()
})
describe('command list', () => {
it('lists the available CLI commands when none is provided', async () => {
process.argv = ['node', 'cli.js']
const { main } = await import('./cli')
await expect(main()).rejects.toThrow('process.exit called')
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('record, dev, test')
)
// The browser sign-in `login` command is gone from the OTP-based flow, so
// it must not be advertised as a top-level command.
const availableCommandsCall = loggerErrorSpy.mock.calls.find(
(call) =>
typeof call[0] === 'string' &&
(call[0] as string).startsWith('Available commands:')
)
expect(availableCommandsCall).toBeDefined()
expect(availableCommandsCall?.[0]).not.toContain('login')
})
})
describe('requireScreenCISecret', () => {
it('returns the secret and prod api url when SCREENCI_SECRET is set', async () => {
process.env.SCREENCI_SECRET = 'secret-from-env'
const { requireScreenCISecret } = await import('./cli')
const result = await requireScreenCISecret()
expect(result.secret).toBe('secret-from-env')
expect(result.apiUrl).toBe('https://api.screenci.com')
expect(result.resolvedConfigPath).toContain('screenci.config.ts')
})
it('logs the secrets url and exits non-zero when SCREENCI_SECRET is missing', async () => {
delete process.env.SCREENCI_SECRET
const { requireScreenCISecret } = await import('./cli')
await expect(requireScreenCISecret()).rejects.toThrow(
'process.exit called'
)
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('SCREENCI_SECRET')
)
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('/secrets')
)
expect(processExitSpy).toHaveBeenCalledWith(1)
})
})
describe('detectInteractiveSession', () => {
it('is interactive when both streams are a TTY and no automation flag is set', async () => {
const { detectInteractiveSession } = await import('./cli')
expect(
detectInteractiveSession({}, { isTTY: true }, { isTTY: true })
).toBe(true)
})
it('is not interactive without a TTY', async () => {
const { detectInteractiveSession } = await import('./cli')
expect(
detectInteractiveSession({}, { isTTY: false }, { isTTY: false })
).toBe(false)
})
it('is not interactive under CI', async () => {
const { detectInteractiveSession } = await import('./cli')
expect(
detectInteractiveSession(
{ CI: 'true' },
{ isTTY: true },
{ isTTY: true }
)
).toBe(false)
})
it('is not interactive when SCREENCI_NONINTERACTIVE=1', async () => {
const { detectInteractiveSession } = await import('./cli')
expect(
detectInteractiveSession(
{ SCREENCI_NONINTERACTIVE: '1' },
{ isTTY: true },
{ isTTY: true }
)
).toBe(false)
})
})
})