mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
1e46e5726c
CI runs prettier from package-lock (3.8.3) while the repo was formatted with a newer local install, so format:check failed on main. Bump prettier to ^3.9.6 in both lockfiles so CI and local formatting agree. The Node 18 smoke test broke because the latest skills CLI requires a newer Node.js runtime. The AI skills install is an optional add-on, so init now warns and continues instead of failing when it errors.
2450 lines
81 KiB
TypeScript
2450 lines
81 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 { stripVTControlCharacters } from 'util'
|
|
import pc from 'picocolors'
|
|
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 mockCopyFile = 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(),
|
|
warn: vi.fn().mockReturnThis(),
|
|
stop: vi.fn().mockReturnThis(),
|
|
}
|
|
const mockOra = vi.fn().mockReturnValue(mockSpinner)
|
|
|
|
// React overlay support, scaffolded by default, appends these to the shared
|
|
// dev-dependency install (after the optional playwright-cli entry).
|
|
const REACT_INSTALL_PACKAGES = [
|
|
'react@^19.0.0',
|
|
'react-dom@^19.0.0',
|
|
'@types/react@^19.0.0',
|
|
'@types/react-dom@^19.0.0',
|
|
'vite@^7.0.0',
|
|
]
|
|
|
|
function expectNpmDevInstalls(
|
|
mockSpawn: ReturnType<typeof vi.fn>,
|
|
cwd: string,
|
|
screenciVersion = '0.0.32',
|
|
includePlaywrightCli = true,
|
|
includeReact = 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'
|
|
)
|
|
|
|
// Packages sharing identical flags install in one command; screenci is
|
|
// installed separately.
|
|
const expectedCalls = [
|
|
[
|
|
'install',
|
|
'--save-dev',
|
|
`@playwright/test@^1.59.0`,
|
|
'@types/node@^25.9.1',
|
|
'prettier@^3.6.0',
|
|
...(includePlaywrightCli ? ['@playwright/cli@latest'] : []),
|
|
...(includeReact ? REACT_INSTALL_PACKAGES : []),
|
|
],
|
|
['install', '--save-dev', `screenci@${screenciVersion}`],
|
|
]
|
|
|
|
expect(npmInstallCalls).toEqual(
|
|
expect.arrayContaining(
|
|
expectedCalls.map((args) => [
|
|
'npm',
|
|
args,
|
|
expect.objectContaining({ cwd, stdio: 'pipe' }),
|
|
])
|
|
)
|
|
)
|
|
}
|
|
|
|
function expectPnpmDevInstalls(
|
|
mockSpawn: ReturnType<typeof vi.fn>,
|
|
cwd: string,
|
|
screenciVersion = '0.0.32',
|
|
includePlaywrightCli = true,
|
|
includeReact = 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`,
|
|
'@types/node@^25.9.1',
|
|
'prettier@^3.6.0',
|
|
...(includePlaywrightCli ? ['@playwright/cli@latest'] : []),
|
|
...(includeReact ? REACT_INSTALL_PACKAGES : []),
|
|
],
|
|
['add', '--save-dev', `screenci@${screenciVersion}`],
|
|
]
|
|
|
|
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,
|
|
includeReact = 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`,
|
|
'@types/node@^25.9.1',
|
|
'prettier@^3.6.0',
|
|
...(includePlaywrightCli ? ['@playwright/cli@latest'] : []),
|
|
...(includeReact ? REACT_INSTALL_PACKAGES : []),
|
|
],
|
|
['add', '--dev', `screenci@${screenciVersion}`],
|
|
]
|
|
|
|
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,
|
|
copyFile: mockCopyFile,
|
|
rm: mockRm,
|
|
readdir: mockReaddir,
|
|
readFile: mockReadFile,
|
|
stat: mockStat,
|
|
writeFile: mockWriteFile,
|
|
mkdir: mockMkdir,
|
|
default: {
|
|
appendFile: mockAppendFile,
|
|
copyFile: mockCopyFile,
|
|
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)
|
|
mockCopyFile.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.warn.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('init command', () => {
|
|
beforeEach(() => {
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
process.nextTick(() => {
|
|
mockChildProcess.stdout.emit('data', '11.0.8\n')
|
|
mockChildProcess.emit('close', 0)
|
|
})
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
})
|
|
process.env.SCREENCI_SECRET = 'test-secret'
|
|
})
|
|
|
|
it('writes init files into a self-contained screenci/ island', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'My Project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockMkdir).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings',
|
|
{
|
|
recursive: true,
|
|
}
|
|
)
|
|
// The brand intro logo is copied into the (gitignored) assets folder.
|
|
expect(mockMkdir).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/assets',
|
|
{
|
|
recursive: true,
|
|
}
|
|
)
|
|
expect(mockCopyFile).toHaveBeenCalledWith(
|
|
expect.stringContaining('logo.png'),
|
|
'/workspace/my-app/screenci/recordings/assets/logo.png'
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/screenci.config.ts',
|
|
expect.stringContaining('"My Project"')
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/screenci.config.ts',
|
|
expect.stringContaining('workers: process.env.CI ? 1 : undefined')
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/screenci.config.ts',
|
|
expect.stringContaining('fullyParallel: true')
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/example.screenci.ts',
|
|
expect.stringContaining("'How to find docs'")
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/example.screenci.ts',
|
|
expect.stringContaining('await page.setContent(landingPageHtml())')
|
|
)
|
|
// With React overlays on (the default), the screenshot example remains a
|
|
// `.ts` file and rings a locator with a `.tsx` overlay page.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/example-screenshot.screenci.ts',
|
|
expect.stringContaining("path: './assets/Ring.tsx'")
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/assets/Ring.tsx',
|
|
expect.stringContaining('export default function Ring()')
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/example-overlays.screenci.tsx',
|
|
expect.any(String)
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/assets/Highlight.tsx',
|
|
expect.any(String)
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/recordings/example-screenshot.screenci.tsx',
|
|
expect.any(String)
|
|
)
|
|
const tsconfigCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-app/screenci/tsconfig.json'
|
|
)
|
|
expect(tsconfigCall).toBeDefined()
|
|
const tsconfig = JSON.parse(tsconfigCall![1] as string) as {
|
|
compilerOptions?: Record<string, unknown>
|
|
}
|
|
// `bundler` resolution lets TypeScript read screenci's ESM `exports` map.
|
|
expect(tsconfig.compilerOptions?.['moduleResolution']).toBe('bundler')
|
|
expect(tsconfig.compilerOptions?.['module']).toBe('ESNext')
|
|
// `types: ['node']` makes `process.env.CI` in screenci.config.ts resolve
|
|
// without relying on TS auto-discovery of @types/node.
|
|
expect(tsconfig.compilerOptions?.['types']).toEqual(['node'])
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.env',
|
|
''
|
|
)
|
|
// The island gets its own fresh package.json (with type:module + run
|
|
// scripts); the host package.json is never touched.
|
|
const islandPackageJsonCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-app/screenci/package.json'
|
|
)
|
|
expect(islandPackageJsonCall).toBeDefined()
|
|
const islandPkg = JSON.parse(
|
|
islandPackageJsonCall![1] as string
|
|
) as Record<string, unknown>
|
|
expect(islandPkg['type']).toBe('module')
|
|
expect(islandPkg['name']).toBe('my-project')
|
|
expect(islandPkg['scripts']).toMatchObject({
|
|
test: 'screenci test',
|
|
edit: 'screenci edit',
|
|
export: 'screenci export',
|
|
})
|
|
expect(islandPkg['scripts']).not.toHaveProperty('screenci')
|
|
// Init enables codegen formatting: a user-editable .prettierrc gates it.
|
|
const prettierrcCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-app/screenci/.prettierrc'
|
|
)
|
|
expect(prettierrcCall).toBeDefined()
|
|
expect(JSON.parse(prettierrcCall![1] as string)).toEqual({
|
|
tabWidth: 2,
|
|
useTabs: false,
|
|
semi: false,
|
|
singleQuote: true,
|
|
})
|
|
// The island gets a minimal README documenting the run scripts and
|
|
// linking to the docs.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/README.md',
|
|
expect.stringContaining('https://screenci.com/docs')
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/package.json',
|
|
expect.any(String)
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.stringContaining('# ScreenCI')
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.stringContaining('# Playwright')
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.stringContaining(
|
|
'node_modules/\n/test-results/\n/playwright-report/\n/blob-report/\n/playwright/.cache/\n/playwright/.auth/'
|
|
)
|
|
)
|
|
expectNpmDevInstalls(mockSpawn, '/workspace/my-app/screenci')
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
`${pc.green('✔ Success!')} Created a ScreenCI project at /workspace/my-app/screenci`
|
|
)
|
|
})
|
|
|
|
it('uses adding labels for successful init spinners', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'My Project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
'Installing selected AI skills'
|
|
)
|
|
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
'Installing dependencies'
|
|
)
|
|
expect(mockSpinner.succeed).toHaveBeenCalledWith('Installing ScreenCI')
|
|
expect(mockSpinner.succeed).not.toHaveBeenCalledWith(
|
|
'Selected AI skills added'
|
|
)
|
|
})
|
|
|
|
it('continues init with a warning when the AI skills install fails', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'My Project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
process.nextTick(() => {
|
|
mockChildProcess.stdout.emit('data', '11.0.8\n')
|
|
mockChildProcess.emit('close', 0)
|
|
})
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
const isSkillsInstall =
|
|
Array.isArray(args) && args.includes('skills') && args.includes('add')
|
|
process.nextTick(() =>
|
|
mockChildProcess.emit('close', isSkillsInstall ? 1 : 0)
|
|
)
|
|
return mockChildProcess as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpinner.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'AI skills install failed, continuing without the AI agent skills'
|
|
)
|
|
)
|
|
expect(mockSpinner.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('You can retry later with:')
|
|
)
|
|
// Init still finishes: dependencies install and success is reported.
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
`${pc.green('✔ Success!')} Created a ScreenCI project at /workspace/my-app/screenci`
|
|
)
|
|
expect(processExitSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the current directory basename as the default project name', async () => {
|
|
process.argv = ['node', 'cli.js', 'init']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/screenci-docs'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: 'Project name:',
|
|
default: 'screenci-docs',
|
|
})
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/screenci-docs/screenci/screenci.config.ts',
|
|
expect.stringContaining('"screenci-docs"')
|
|
)
|
|
})
|
|
|
|
it('appends to an existing .gitignore instead of overwriting it', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'My Project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockImplementation((path: string) => {
|
|
const pathString = String(path)
|
|
if (pathString === '/workspace/my-app/screenci/.gitignore') {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
mockReadFile.mockImplementation(async (path: string | URL) => {
|
|
if (String(path).endsWith('/workspace/my-app/screenci/.gitignore')) {
|
|
return 'dist/'
|
|
}
|
|
if (String(path).endsWith('package.json')) {
|
|
return JSON.stringify({ version: '0.0.32' })
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.any(String)
|
|
)
|
|
expect(mockAppendFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.stringContaining('\n\n# ScreenCI')
|
|
)
|
|
expect(mockAppendFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.gitignore',
|
|
expect.stringContaining(
|
|
'# Playwright\nnode_modules/\n/test-results/\n/playwright-report/\n/blob-report/\n/playwright/.cache/\n/playwright/.auth/'
|
|
)
|
|
)
|
|
})
|
|
|
|
it('writes a fresh island package.json and never touches an existing host package.json', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'My Project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockImplementation((path: string) => {
|
|
const pathString = String(path)
|
|
// A host package.json already exists at the repo root.
|
|
if (pathString === '/workspace/my-app/package.json') {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
mockReadFile.mockImplementation(async (path: string | URL) => {
|
|
if (String(path).endsWith('package.json')) {
|
|
return JSON.stringify({ type: 'module', version: '0.0.32' })
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// Host package.json is left completely untouched.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/package.json',
|
|
expect.any(String)
|
|
)
|
|
// The island gets its own fresh package.json.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/package.json',
|
|
expect.stringContaining('"type": "module"')
|
|
)
|
|
expectNpmDevInstalls(mockSpawn, '/workspace/my-app/screenci')
|
|
expect(loggerInfoSpy).not.toHaveBeenCalledWith(' package.json')
|
|
})
|
|
|
|
it('prompts in the new order with the new wording', async () => {
|
|
process.argv = ['node', 'cli.js', 'init']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/demo-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: 'Project name:',
|
|
default: 'demo-app',
|
|
})
|
|
)
|
|
// The wizard now asks only the decisions that genuinely vary: project
|
|
// name, the GitHub workflow, and the combined AI skills. React, Playwright
|
|
// browsers, and OS deps are auto-applied at their defaults (steerable via
|
|
// flags), so they are no longer prompted.
|
|
expect(mockInput.mock.calls.map((call) => call[0])).toEqual([
|
|
expect.objectContaining({
|
|
message: 'Project name:',
|
|
default: 'demo-app',
|
|
}),
|
|
expect.objectContaining({
|
|
message: 'Add a GitHub Actions CI workflow? (Y/n)',
|
|
default: 'Y',
|
|
}),
|
|
expect.objectContaining({
|
|
message:
|
|
'Install AI agent skills (ScreenCI + playwright-cli) for your coding agent? (Y/n)',
|
|
default: 'Y',
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('does not print the auto-applied defaults summary in normal mode', async () => {
|
|
process.argv = ['node', 'cli.js', 'init']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/demo-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(loggerInfoSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('Using defaults (override with flags):')
|
|
)
|
|
})
|
|
|
|
it('prints the auto-applied defaults summary in verbose mode', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', '--verbose']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/demo-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
'Using defaults (override with flags): React overlays on (--no-react), Playwright browsers on (--no-playwright-browsers), OS deps off (--playwright-os-deps).'
|
|
)
|
|
})
|
|
|
|
it('uses default answers with --yes', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', '--yes']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/demo-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockInput).not.toHaveBeenCalled()
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/demo-app/.github/workflows/screenci.yaml',
|
|
expect.any(String)
|
|
)
|
|
// Skills install at the repo root so coding agents discover them.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npm',
|
|
[
|
|
'exec',
|
|
'--yes',
|
|
'--package=skills',
|
|
'--',
|
|
'skills',
|
|
'add',
|
|
'screenci/screenci',
|
|
'--skill',
|
|
'screenci',
|
|
'--skill',
|
|
'playwright-cli',
|
|
'-y',
|
|
],
|
|
expect.objectContaining({ cwd: '/workspace/demo-app', stdio: 'pipe' })
|
|
)
|
|
expectNpmDevInstalls(mockSpawn, '/workspace/demo-app/screenci')
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install', '--only-shell', 'chromium'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/demo-app/screenci',
|
|
stdio: 'inherit',
|
|
})
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install-deps', 'chromium'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('creates the workflow at the repo root scoped to the island', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const workflowCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
typeof call[0] === 'string' && call[0].endsWith('screenci.yaml')
|
|
)
|
|
expect(workflowCall?.[0]).toBe(
|
|
'/workspace/my-project/.github/workflows/screenci.yaml'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('working-directory: screenci')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'cache-dependency-path: screenci/package-lock.json'
|
|
)
|
|
expect(workflowCall?.[1]).toContain(
|
|
'Copy it from https://app.screenci.com/secrets or ./.env'
|
|
)
|
|
expect(workflowCall?.[1]).not.toContain('actions/cache@v5')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'run: npx playwright install --only-shell chromium'
|
|
)
|
|
expect(workflowCall?.[1]).not.toContain('--with-deps')
|
|
// Nested island: carries commented hints for recording a locally-built
|
|
// parent app (extend the cache path, install + build the app), keyed to
|
|
// the detected package manager.
|
|
expect(workflowCall?.[1]).toContain(
|
|
'# package-lock.json\n # screenci/package-lock.json'
|
|
)
|
|
expect(workflowCall?.[1]).toContain(
|
|
'# - name: Install app dependencies'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('# run: npm ci')
|
|
expect(workflowCall?.[1]).toContain('# - name: Build app')
|
|
expect(workflowCall?.[1]).toContain('# run: npm run build')
|
|
})
|
|
|
|
it('supports pnpm init flows end to end', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'pnpm',
|
|
['exec', 'playwright', 'install', '--only-shell', 'chromium'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'inherit',
|
|
})
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/README.md',
|
|
expect.any(String)
|
|
)
|
|
// The island is its own pnpm workspace root.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/pnpm-workspace.yaml',
|
|
expect.stringContaining('ffmpeg-static')
|
|
)
|
|
const workflowCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
typeof call[0] === 'string' && call[0].endsWith('screenci.yaml')
|
|
)
|
|
expect(workflowCall?.[1]).toContain('cache: pnpm')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'cache-dependency-path: screenci/pnpm-lock.yaml'
|
|
)
|
|
expect(workflowCall?.[1]).not.toContain('HUSKY: 0')
|
|
expect(workflowCall?.[1]).not.toContain('npm_config_strict_dep_builds')
|
|
expect(workflowCall?.[1]).toContain('run: pnpm install --frozen-lockfile')
|
|
expect(workflowCall?.[1]).not.toContain('actions/cache@v5')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'run: pnpm exec playwright install --only-shell chromium'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('pnpm exec screenci export')
|
|
// The commented local-app build hint tracks the detected package manager.
|
|
expect(workflowCall?.[1]).toContain(
|
|
'# run: pnpm install --frozen-lockfile'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('# run: pnpm run build')
|
|
// Targeted recordings: optional `grep` input forwarded to record.
|
|
expect(workflowCall?.[1]).toContain('SCREENCI_GREP: ${{ inputs.grep }}')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'pnpm exec screenci export --grep "$SCREENCI_GREP"'
|
|
)
|
|
expect(workflowCall?.[1]).toMatch(/workflow_dispatch:\s*\n\s*inputs:/)
|
|
})
|
|
|
|
it('defaults to pnpm when invoked from a pnpm user agent', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--yes']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
process.env.npm_config_user_agent = 'pnpm/11.0.8 npm/? node/v24.0.0'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/README.md',
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('defaults to yarn when invoked from a yarn user agent', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--yes']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
process.env.npm_config_user_agent = 'yarn/4.9.1 npm/? node/v24.0.0'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectYarnDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
})
|
|
|
|
it('falls back to pnpm from a repo pnpm-lock.yaml when no user agent is set', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--yes']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
// No package manager set a user agent (e.g. a global/direct `screenci
|
|
// init`); the island matches the surrounding pnpm repo.
|
|
mockExistsSync.mockImplementation((p: string) =>
|
|
p.endsWith('pnpm-lock.yaml')
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
})
|
|
|
|
it('falls back to pnpm from a host packageManager field when no user agent is set', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--yes']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockReadFileSync.mockImplementation((p: string) => {
|
|
if (p.endsWith('package.json')) {
|
|
return JSON.stringify({ packageManager: 'pnpm@11.0.9' })
|
|
}
|
|
return 'VITE_APP_BASE_URL=https://example.com\n'
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
})
|
|
|
|
it('installs into the island without -w even inside a pnpm workspace', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
// Simulate a surrounding pnpm workspace at the repo root.
|
|
mockExistsSync.mockImplementation((p: string) =>
|
|
p.endsWith('/workspace/my-project/pnpm-workspace.yaml')
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// No -w flag: the island is its own isolated project, installed locally.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'pnpm',
|
|
[
|
|
'add',
|
|
'--save-dev',
|
|
'@playwright/test@^1.59.0',
|
|
'@types/node@^25.9.1',
|
|
'prettier@^3.6.0',
|
|
'@playwright/cli@latest',
|
|
...REACT_INSTALL_PACKAGES,
|
|
],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'pnpm',
|
|
['add', '--save-dev', 'screenci@0.0.32'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
// The island declares its own pnpm workspace root so the parent does not
|
|
// absorb it.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/pnpm-workspace.yaml',
|
|
expect.stringContaining('ffmpeg-static')
|
|
)
|
|
})
|
|
|
|
it('supports yarn init flows end to end', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'yarn',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expectYarnDevInstalls(mockSpawn, '/workspace/my-project/screenci')
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'yarn',
|
|
['playwright', 'install', '--only-shell', 'chromium'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'inherit',
|
|
})
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/.yarnrc.yml',
|
|
'nodeLinker: node-modules\n'
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/.gitignore',
|
|
expect.stringContaining('.yarn/')
|
|
)
|
|
const workflowCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
typeof call[0] === 'string' && call[0].endsWith('screenci.yaml')
|
|
)
|
|
expect(workflowCall?.[1]).toContain('cache: yarn')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'cache-dependency-path: screenci/yarn.lock'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('run: yarn install --frozen-lockfile')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'run: yarn playwright install --only-shell chromium'
|
|
)
|
|
expect(workflowCall?.[1]).toContain('yarn screenci export')
|
|
expect(workflowCall?.[1]).toContain(
|
|
'yarn screenci export --grep "$SCREENCI_GREP"'
|
|
)
|
|
})
|
|
|
|
it('writes a fresh island package.json without touching a host package.json that lacks type:module', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'npm',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
// A host package.json exists at the repo root with no "type": "module".
|
|
mockExistsSync.mockImplementation((p: string) =>
|
|
p.endsWith('/workspace/my-project/package.json')
|
|
)
|
|
mockReadFile.mockImplementation(async (path: string | URL) => {
|
|
if (String(path).endsWith('package.json')) {
|
|
return JSON.stringify({
|
|
name: 'smoke-workspace',
|
|
private: true,
|
|
version: '0.0.1',
|
|
})
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// Host package.json is never modified.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/package.json',
|
|
expect.any(String)
|
|
)
|
|
const packageJsonCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-project/screenci/package.json'
|
|
)
|
|
expect(packageJsonCall).toBeDefined()
|
|
const written = JSON.parse(packageJsonCall![1] as string) as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
expect(written['type']).toBe('module')
|
|
expect(written['name']).toBe('my-project')
|
|
})
|
|
|
|
it('installs into the island without -W even with yarn workspaces', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'yarn',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockReadFileSync.mockImplementation((p: string) => {
|
|
if (p.endsWith('package.json')) {
|
|
return JSON.stringify({ workspaces: ['packages/*'] })
|
|
}
|
|
return 'VITE_APP_BASE_URL=https://example.com\n'
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'yarn',
|
|
[
|
|
'add',
|
|
'--dev',
|
|
'@playwright/test@^1.59.0',
|
|
'@types/node@^25.9.1',
|
|
'prettier@^3.6.0',
|
|
'@playwright/cli@latest',
|
|
...REACT_INSTALL_PACKAGES,
|
|
],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('adds @playwright/cli only when the AI skills prompt is accepted', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
// Project name comes from argv. Order is now: github workflow, AI skills.
|
|
mockInput
|
|
.mockResolvedValueOnce('') // github workflow
|
|
.mockResolvedValueOnce('n') // AI skills -> declined
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// Declining the AI skills prompt drops @playwright/cli from the dev batch.
|
|
expectNpmDevInstalls(
|
|
mockSpawn,
|
|
'/workspace/my-project/screenci',
|
|
'0.0.32',
|
|
false
|
|
)
|
|
})
|
|
|
|
it('uses the configured screenci dependency override verbatim', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
process.env.SCREENCI_INIT_SCREENCI_DEPENDENCY =
|
|
'file:./screenci-0.0.44.tgz'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npm',
|
|
['install', '--save-dev', 'screenci@file:./screenci-0.0.44.tgz'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('installs the ScreenCI skill but drops playwright-cli with --no-playwright-cli', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--no-playwright-cli',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
// Project name comes from argv. Order is now: github workflow, AI skills.
|
|
mockInput
|
|
.mockResolvedValueOnce('') // github workflow -> default yes
|
|
.mockResolvedValueOnce('') // AI skills -> default yes
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// The AI skills prompt was accepted, but --no-playwright-cli keeps only
|
|
// the ScreenCI skill.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npm',
|
|
[
|
|
'exec',
|
|
'--yes',
|
|
'--package=skills',
|
|
'--',
|
|
'skills',
|
|
'add',
|
|
'screenci/screenci',
|
|
'--skill',
|
|
'screenci',
|
|
'-y',
|
|
],
|
|
expect.objectContaining({ cwd: '/workspace/my-project', stdio: 'pipe' })
|
|
)
|
|
// @playwright/cli is dropped from the dev-dependency batch.
|
|
expectNpmDevInstalls(
|
|
mockSpawn,
|
|
'/workspace/my-project/screenci',
|
|
'0.0.32',
|
|
false
|
|
)
|
|
})
|
|
|
|
it('passes --agent through to the executed skills command', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--agent',
|
|
'opencode',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npm',
|
|
expect.arrayContaining(['--agent', 'opencode']),
|
|
expect.objectContaining({ cwd: '/workspace/my-project' })
|
|
)
|
|
})
|
|
|
|
it('splits browser install from operating system dependencies', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install', '--only-shell', 'chromium'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'inherit',
|
|
})
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install-deps', 'chromium'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('runs install-deps without sudo with --playwright-os-deps', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--playwright-os-deps',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install-deps', 'chromium'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'inherit',
|
|
})
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'sudo',
|
|
expect.anything(),
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('prints next steps including cd into the island', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const rawMessages = loggerInfoSpy.mock.calls.map((call) =>
|
|
String(call[0])
|
|
)
|
|
const messages = rawMessages.map((message) =>
|
|
stripVTControlCharacters(message)
|
|
)
|
|
|
|
expect(rawMessages).toContain(` ${pc.cyan('npx screenci test')}`)
|
|
expect(messages).toContain(' Tests your video scripts fast locally.')
|
|
expect(messages).toContain(
|
|
' Tests your video scripts in interactive UI mode.'
|
|
)
|
|
expect(rawMessages).not.toContain(` ${pc.cyan('npx screenci login')}`)
|
|
expect(messages).toContain(
|
|
' Records locally and uploads the result to ScreenCI.'
|
|
)
|
|
expect(rawMessages).toContain(
|
|
'Visit ' +
|
|
pc.cyan('https://screenci.com/docs') +
|
|
' for more information.'
|
|
)
|
|
expect(messages).toContain(
|
|
'Recording during an anonymous trial agrees to the Terms: https://screenci.com/legal/tos'
|
|
)
|
|
expect(rawMessages).toContain(
|
|
'Recording during an anonymous trial agrees to the Terms: https://screenci.com/legal/tos'
|
|
)
|
|
expect(messages).toContain('You can now run these commands:')
|
|
expect(messages).toContain('We suggest that you begin by typing:')
|
|
expect(messages).toContain(' cd screenci')
|
|
expect(messages).toContain(' npx screenci test')
|
|
expect(messages).toContain(
|
|
' - ./screenci/recordings/ - Videos and screenshots'
|
|
)
|
|
expect(messages).toContain(
|
|
' - ./screenci/screenci.config.ts - ScreenCI configuration'
|
|
)
|
|
})
|
|
|
|
it('verifies a pasted SCREENCI_SECRET positional and writes it to the island .env', async () => {
|
|
// A leading positional shaped like a v4 UUID is a pasted secret, not the
|
|
// project name: init verifies it and the project keeps the default name.
|
|
delete process.env.SCREENCI_SECRET
|
|
const pastedSecret = '11111111-1111-4111-8111-111111111111'
|
|
process.argv = ['node', 'cli.js', 'init', pastedSecret, '-y']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockFetch.mockImplementation(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/whoami')) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({ orgId: 'org_123' }),
|
|
text: vi.fn().mockResolvedValue(''),
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: vi.fn().mockResolvedValue({}),
|
|
text: vi.fn().mockResolvedValue(''),
|
|
}
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// The verified secret is persisted into the island .env for record.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.env',
|
|
`SCREENCI_SECRET=${pastedSecret}\n`
|
|
)
|
|
// The project name falls back to the directory default, never the secret.
|
|
const configCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-app/screenci/screenci.config.ts'
|
|
)
|
|
expect(configCall).toBeDefined()
|
|
expect(configCall![1]).toContain('"my-app"')
|
|
expect(configCall![1]).not.toContain(pastedSecret)
|
|
})
|
|
|
|
it('prints manual secret guidance and creates no secret when nothing is pasted', async () => {
|
|
delete process.env.SCREENCI_SECRET
|
|
process.argv = ['node', 'cli.js', 'init', '-y']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const messages = loggerInfoSpy.mock.calls.map((call) =>
|
|
stripVTControlCharacters(String(call[0]))
|
|
)
|
|
// Manual path: guide the user to copy the secret from the secrets page.
|
|
expect(
|
|
messages.some(
|
|
(message) =>
|
|
message.includes('https://app.screenci.com/secrets') &&
|
|
message.includes('screenci/.env')
|
|
)
|
|
).toBe(true)
|
|
// Nothing pasted means no secret is written into the island .env.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-app/screenci/.env',
|
|
expect.stringContaining('SCREENCI_SECRET=')
|
|
)
|
|
})
|
|
|
|
it('prints pnpm next steps when pnpm is selected', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const rawMessages = loggerInfoSpy.mock.calls.map((call) =>
|
|
String(call[0])
|
|
)
|
|
const messages = rawMessages.map((message) =>
|
|
stripVTControlCharacters(message)
|
|
)
|
|
|
|
expect(rawMessages).toContain(` ${pc.cyan('pnpm exec screenci test')}`)
|
|
expect(messages).toContain(' pnpm exec screenci test')
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'pnpm',
|
|
['--version'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
// The combined AI-skills prompt avoids package-manager-specific install
|
|
// commands; the actual install command is still package-manager aware.
|
|
expect(mockInput.mock.calls.map((call) => call[0])).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message:
|
|
'Install AI agent skills (ScreenCI + playwright-cli) for your coding agent? (Y/n)',
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('checks pnpm version before installing screenci and uses native build approval', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const pnpmVersionCallIndex = mockSpawn.mock.calls.findIndex(
|
|
(call: unknown[]) =>
|
|
call[0] === 'pnpm' &&
|
|
Array.isArray(call[1]) &&
|
|
call[1][0] === '--version'
|
|
)
|
|
const screenciInstallCallIndex = mockSpawn.mock.calls.findIndex(
|
|
(call: unknown[]) =>
|
|
call[0] === 'pnpm' &&
|
|
Array.isArray(call[1]) &&
|
|
call[1][0] === 'add' &&
|
|
call[1].includes('screenci@0.0.32')
|
|
)
|
|
|
|
expect(pnpmVersionCallIndex).toBeGreaterThanOrEqual(0)
|
|
expect(screenciInstallCallIndex).toBeGreaterThan(pnpmVersionCallIndex)
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'pnpm',
|
|
['add', '--save-dev', 'screenci@0.0.32'],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/my-project/screenci',
|
|
stdio: 'pipe',
|
|
})
|
|
)
|
|
// pnpm 11 (the default mocked version) approves builds via `allowBuilds`,
|
|
// so non-interactive installs build ffmpeg-static without prompting.
|
|
const workspaceCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-project/screenci/pnpm-workspace.yaml'
|
|
)
|
|
expect(workspaceCall?.[1]).toContain(
|
|
'allowBuilds:\n esbuild: true\n ffmpeg-static: true'
|
|
)
|
|
expect(workspaceCall?.[1]).not.toContain('onlyBuiltDependencies')
|
|
// The flat (host) pnpm-workspace.yaml is never written; only the island's.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/pnpm-workspace.yaml',
|
|
expect.any(String)
|
|
)
|
|
expect(mockReadFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/pnpm-workspace.yaml',
|
|
'utf-8'
|
|
)
|
|
})
|
|
|
|
it('uses onlyBuiltDependencies build approval on pnpm 10', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
process.nextTick(() => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
child.stdout.emit('data', '10.26.0\n')
|
|
}
|
|
child.emit('close', 0)
|
|
})
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
const workspaceCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-project/screenci/pnpm-workspace.yaml'
|
|
)
|
|
expect(workspaceCall?.[1]).toContain(
|
|
'onlyBuiltDependencies:\n - esbuild\n - ffmpeg-static'
|
|
)
|
|
expect(workspaceCall?.[1]).not.toContain('allowBuilds')
|
|
})
|
|
|
|
it('fails fast when pnpm cannot be detected', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
|
|
process.nextTick(() => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
child.emit('error', new Error('spawn pnpm ENOENT'))
|
|
return
|
|
}
|
|
|
|
child.emit('close', 0)
|
|
})
|
|
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).rejects.toThrow(
|
|
[
|
|
'pnpm could not be detected. ScreenCI requires pnpm 10.26.0 or newer to use pnpm native --allow-build support for ffmpeg-static.',
|
|
'Upgrade pnpm and rerun, or use `--package-manager npm`.',
|
|
'Examples:',
|
|
' corepack use pnpm@latest',
|
|
' pnpm create screenci',
|
|
' npm init screenci@latest',
|
|
].join('\n')
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'pnpm',
|
|
expect.arrayContaining(['add']),
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('fails fast when pnpm is older than 10.26.0', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'pnpm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
|
|
process.nextTick(() => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
child.stdout.emit('data', '10.25.9\n')
|
|
child.emit('close', 0)
|
|
return
|
|
}
|
|
|
|
child.emit('close', 0)
|
|
})
|
|
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).rejects.toThrow(
|
|
[
|
|
'Detected pnpm 10.25.9. ScreenCI requires pnpm 10.26.0 or newer because it relies on pnpm native --allow-build support for ffmpeg-static.',
|
|
'Upgrade pnpm and rerun, or use `--package-manager npm`.',
|
|
'Examples:',
|
|
' corepack use pnpm@latest',
|
|
' pnpm create screenci',
|
|
' npm init screenci@latest',
|
|
].join('\n')
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'pnpm',
|
|
expect.arrayContaining(['add']),
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('fails fast when yarn cannot be detected', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'yarn',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
process.nextTick(() => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
child.emit('error', new Error('spawn yarn ENOENT'))
|
|
return
|
|
}
|
|
child.emit('close', 0)
|
|
})
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).rejects.toThrow(
|
|
[
|
|
'yarn could not be detected. ScreenCI requires yarn 2+ (yarn berry) because it uses `yarn dlx` for skill installation.',
|
|
'Upgrade to yarn 2+ and rerun, or use a different package manager:',
|
|
' corepack enable && corepack prepare yarn@stable --activate',
|
|
' yarn create screenci',
|
|
' npm init screenci@latest',
|
|
].join('\n')
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails fast when yarn 1.x is detected and corepack yarn is also v1', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'yarn',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
process.nextTick(() => {
|
|
// yarn --version → v1; corepack yarn --version → v1 (no escape hatch)
|
|
if (
|
|
(command === 'yarn' && args[0] === '--version') ||
|
|
(command === 'corepack' &&
|
|
args[0] === 'yarn' &&
|
|
args[1] === '--version')
|
|
) {
|
|
child.stdout.emit('data', '1.22.22\n')
|
|
child.emit('close', 0)
|
|
return
|
|
}
|
|
child.emit('close', 0)
|
|
})
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).rejects.toThrow(
|
|
[
|
|
'Detected yarn 1.22.22. ScreenCI requires yarn 2+ (yarn berry) because it uses `yarn dlx` for skill installation.',
|
|
'Upgrade to yarn 2+ and rerun, or use a different package manager:',
|
|
' corepack enable && corepack prepare yarn@stable --activate',
|
|
' yarn create screenci',
|
|
' npm init screenci@latest',
|
|
].join('\n')
|
|
)
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'yarn',
|
|
expect.arrayContaining(['add']),
|
|
expect.anything()
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('proceeds when yarn 1.x shadows the corepack shim but corepack yarn is v2+', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'yarn',
|
|
'--yes',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
mockSpawn.mockImplementation((command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
process.nextTick(() => {
|
|
if (command === 'yarn' && args[0] === '--version') {
|
|
// pre-installed yarn 1.x shadows corepack in PATH
|
|
child.stdout.emit('data', '1.22.22\n')
|
|
child.emit('close', 0)
|
|
return
|
|
}
|
|
if (
|
|
command === 'corepack' &&
|
|
args[0] === 'yarn' &&
|
|
args[1] === '--version'
|
|
) {
|
|
// corepack has berry activated
|
|
child.stdout.emit('data', '4.9.1\n')
|
|
child.emit('close', 0)
|
|
return
|
|
}
|
|
child.emit('close', 0)
|
|
})
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await expect(main()).resolves.toBeUndefined()
|
|
expect(mockWriteFile).toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips pnpm version checks when npm is explicitly selected', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--package-manager',
|
|
'npm',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
process.env.npm_config_user_agent = 'pnpm/11.0.8 npm/? node/v24.0.0'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'pnpm',
|
|
['--version'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('skips the workflow (without failing) when one already exists', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockImplementation(
|
|
(path: string) =>
|
|
path === '/workspace/my-project/.github/workflows/screenci.yaml'
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
await expect(main()).resolves.toBeUndefined()
|
|
|
|
// The existing workflow is left intact and the rest of init still runs.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/.github/workflows/screenci.yaml',
|
|
expect.any(String)
|
|
)
|
|
const messages = loggerInfoSpy.mock.calls.map((call) => String(call[0]))
|
|
expect(
|
|
messages.some((message) =>
|
|
message.includes(
|
|
'Skipping GitHub Actions workflow: .github/workflows/screenci.yaml already exists'
|
|
)
|
|
)
|
|
).toBe(true)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/screenci.config.ts',
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('fails when the screenci/ island already exists', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockImplementation(
|
|
(path: string) => path === '/workspace/my-project/screenci'
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
await expect(main()).rejects.toThrow('process.exit called')
|
|
|
|
// Nothing is scaffolded when the island is already present.
|
|
expect(mockMkdir).not.toHaveBeenCalled()
|
|
expect(mockWriteFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rolls back the island it created when an install fails', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
mockSpawn.mockImplementation((command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
process.nextTick(() => {
|
|
if (
|
|
command === 'npm' &&
|
|
Array.isArray(args) &&
|
|
args[0] === 'install' &&
|
|
args[2] === 'screenci@0.0.32'
|
|
) {
|
|
child.stderr.emit('data', 'boom')
|
|
child.emit('close', 1)
|
|
return
|
|
}
|
|
child.emit('close', 0)
|
|
})
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await expect(main()).rejects.toThrow('npm exited with code 1')
|
|
|
|
// The partially-created island is removed so a re-run starts clean.
|
|
expect(mockRmSync).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci',
|
|
{
|
|
recursive: true,
|
|
force: true,
|
|
}
|
|
)
|
|
})
|
|
|
|
it('surfaces package manager stderr when an init install fails', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
mockSpawn.mockImplementation((command: string, args: string[]) => {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
unref: vi.fn(),
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
})
|
|
|
|
process.nextTick(() => {
|
|
if (
|
|
command === 'npm' &&
|
|
Array.isArray(args) &&
|
|
args[0] === 'install' &&
|
|
args[2] === 'screenci@0.0.32'
|
|
) {
|
|
child.stderr.emit('data', 'No matching version found for screenci')
|
|
child.emit('close', 1)
|
|
return
|
|
}
|
|
|
|
child.emit('close', 0)
|
|
})
|
|
|
|
return child as unknown as ChildProcess
|
|
})
|
|
|
|
const { main } = await import('./cli')
|
|
await expect(main()).rejects.toThrow(
|
|
'npm exited with code 1: No matching version found for screenci'
|
|
)
|
|
})
|
|
|
|
it('removes the old init-only flags from the CLI', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', '--install']
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await expect(main()).rejects.toThrow('process.exit called')
|
|
})
|
|
|
|
it('skips React overlay support with --no-react', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--no-react']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// Under --no-react the scaffold writes no .tsx example files, so nothing
|
|
// depends on react/react-dom.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/recordings/example-overlays.screenci.tsx',
|
|
expect.any(String)
|
|
)
|
|
// Under --no-react the screenshot example is a `.ts` referencing a plain
|
|
// `.html` overlay page, so it never depends on react/react-dom.
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/recordings/example-screenshot.screenci.ts',
|
|
expect.stringContaining("path: './assets/ring.html'")
|
|
)
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/recordings/assets/ring.html',
|
|
expect.stringContaining('<div class="ring"></div>')
|
|
)
|
|
// No .tsx overlay page is scaffolded under --no-react.
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/recordings/assets/Ring.tsx',
|
|
expect.any(String)
|
|
)
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/my-project/screenci/recordings/example-screenshot.screenci.tsx',
|
|
expect.any(String)
|
|
)
|
|
// tsconfig does not enable the automatic JSX runtime.
|
|
const tsconfigCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === '/workspace/my-project/screenci/tsconfig.json'
|
|
)
|
|
expect(tsconfigCall?.[1]).not.toContain('react-jsx')
|
|
// react/react-dom are dropped from the dev-dependency batch.
|
|
expectNpmDevInstalls(
|
|
mockSpawn,
|
|
'/workspace/my-project/screenci',
|
|
'0.0.32',
|
|
true,
|
|
false
|
|
)
|
|
})
|
|
|
|
it('skips Playwright browser install with --no-playwright-browsers', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--no-playwright-browsers',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'npx',
|
|
['playwright', 'install', '--only-shell', 'chromium'],
|
|
expect.anything()
|
|
)
|
|
})
|
|
|
|
it('skips the workflow and its prompt with --no-github-workflow', async () => {
|
|
process.argv = [
|
|
'node',
|
|
'cli.js',
|
|
'init',
|
|
'my-project',
|
|
'--no-github-workflow',
|
|
]
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// The workflow file is not written.
|
|
const workflowCall = mockWriteFile.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
typeof call[0] === 'string' && call[0].endsWith('screenci.yaml')
|
|
)
|
|
expect(workflowCall).toBeUndefined()
|
|
// The GitHub workflow question is never asked; only the AI skills prompt
|
|
// fires (project name comes from argv).
|
|
expect(mockInput.mock.calls.map((call) => call[0])).toEqual([
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('Install AI agent skills'),
|
|
}),
|
|
])
|
|
})
|
|
|
|
it('skips both AI skills and their prompt with --no-skills', async () => {
|
|
process.argv = ['node', 'cli.js', 'init', 'my-project', '--no-skills']
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/my-project'
|
|
mockExistsSync.mockReturnValue(false)
|
|
|
|
const { main } = await import('./cli')
|
|
await main()
|
|
|
|
// No skills install command runs.
|
|
expect(mockSpawn).not.toHaveBeenCalledWith(
|
|
'npm',
|
|
expect.arrayContaining(['skills', 'add', 'screenci/screenci']),
|
|
expect.anything()
|
|
)
|
|
// @playwright/cli is dropped from the dev-dependency batch.
|
|
expectNpmDevInstalls(
|
|
mockSpawn,
|
|
'/workspace/my-project/screenci',
|
|
'0.0.32',
|
|
false
|
|
)
|
|
// The AI skills question is never asked; only the GitHub workflow prompt
|
|
// fires (project name comes from argv).
|
|
expect(mockInput.mock.calls.map((call) => call[0])).toEqual([
|
|
expect.objectContaining({
|
|
message: 'Add a GitHub Actions CI workflow? (Y/n)',
|
|
}),
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('create-screenci wrapper', () => {
|
|
beforeEach(() => {
|
|
mockSpawn.mockImplementation((_command: string, args: string[]) => {
|
|
if (Array.isArray(args) && args[0] === '--version') {
|
|
process.nextTick(() => {
|
|
mockChildProcess.stdout.emit('data', '11.0.8\n')
|
|
mockChildProcess.emit('close', 0)
|
|
})
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
})
|
|
process.env.SCREENCI_SECRET = 'test-secret'
|
|
process.env.SCREENCI_INIT_CWD = '/workspace/create-app'
|
|
mockExistsSync.mockReturnValue(false)
|
|
})
|
|
|
|
it('prompts for the project name when no args are provided', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli(['node', 'create-screenci.js'])
|
|
|
|
expect(mockInput).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
message: 'Project name:',
|
|
default: 'create-app',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('accepts the project name as the first positional argument', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'Wrapper Project',
|
|
])
|
|
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/create-app/screenci/screenci.config.ts',
|
|
expect.stringContaining('"Wrapper Project"')
|
|
)
|
|
})
|
|
|
|
it('supports --yes without prompting', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli(['node', 'create-screenci.js', '--yes'])
|
|
|
|
expect(mockInput).not.toHaveBeenCalled()
|
|
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
'/workspace/create-app/.github/workflows/screenci.yaml',
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('uses npm installs when --package-manager npm is set', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'--package-manager',
|
|
'npm',
|
|
'--yes',
|
|
])
|
|
|
|
expectNpmDevInstalls(mockSpawn, '/workspace/create-app/screenci')
|
|
})
|
|
|
|
it('uses npm through cmd.exe when --package-manager npm is set on Windows', async () => {
|
|
const platformSpy = vi
|
|
.spyOn(process, 'platform', 'get')
|
|
.mockReturnValue('win32')
|
|
try {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'--package-manager',
|
|
'npm',
|
|
'--yes',
|
|
])
|
|
|
|
const installCall = mockSpawn.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === 'cmd.exe' &&
|
|
Array.isArray(call[1]) &&
|
|
(call[1] as string[])[3] ===
|
|
'""npm.cmd" "install" "--save-dev" "@playwright/test@^1.59.0" "@types/node@^25.9.1" "prettier@^3.6.0" "@playwright/cli@latest" "react@^19.0.0" "react-dom@^19.0.0" "@types/react@^19.0.0" "@types/react-dom@^19.0.0" "vite@^7.0.0""'
|
|
)
|
|
expect(installCall).toEqual([
|
|
'cmd.exe',
|
|
[
|
|
'/d',
|
|
'/s',
|
|
'/c',
|
|
'""npm.cmd" "install" "--save-dev" "@playwright/test@^1.59.0" "@types/node@^25.9.1" "prettier@^3.6.0" "@playwright/cli@latest" "react@^19.0.0" "react-dom@^19.0.0" "@types/react@^19.0.0" "@types/react-dom@^19.0.0" "vite@^7.0.0""',
|
|
],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/create-app/screenci',
|
|
stdio: 'pipe',
|
|
windowsVerbatimArguments: true,
|
|
}),
|
|
])
|
|
|
|
const skillsCall = mockSpawn.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === 'cmd.exe' &&
|
|
Array.isArray(call[1]) &&
|
|
(call[1] as string[])[3] ===
|
|
'""npm.cmd" "exec" "--yes" "--package=skills" "--" "skills" "add" "screenci/screenci" "--skill" "screenci" "--skill" "playwright-cli" "-y""'
|
|
)
|
|
expect(skillsCall).toEqual([
|
|
'cmd.exe',
|
|
[
|
|
'/d',
|
|
'/s',
|
|
'/c',
|
|
'""npm.cmd" "exec" "--yes" "--package=skills" "--" "skills" "add" "screenci/screenci" "--skill" "screenci" "--skill" "playwright-cli" "-y""',
|
|
],
|
|
expect.objectContaining({
|
|
cwd: '/workspace/create-app',
|
|
stdio: 'pipe',
|
|
windowsVerbatimArguments: true,
|
|
}),
|
|
])
|
|
} finally {
|
|
platformSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('uses pnpm installs when --package-manager pnpm is set', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'--package-manager',
|
|
'pnpm',
|
|
'--yes',
|
|
])
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/create-app/screenci')
|
|
})
|
|
|
|
it('defaults to pnpm when invoked from pnpm create', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
process.env.npm_config_user_agent = 'pnpm/11.0.8 npm/? node/v24.0.0'
|
|
|
|
await runCreateScreenciCli(['node', 'create-screenci.js', '--yes'])
|
|
|
|
expectPnpmDevInstalls(mockSpawn, '/workspace/create-app/screenci')
|
|
expect(mockWriteFile).not.toHaveBeenCalledWith(
|
|
'/workspace/create-app/README.md',
|
|
expect.any(String)
|
|
)
|
|
})
|
|
|
|
it('passes --agent through to the skills command', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'--agent',
|
|
'opencode',
|
|
])
|
|
|
|
const skillsCall = mockSpawn.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === 'npm' &&
|
|
Array.isArray(call[1]) &&
|
|
(call[1] as string[]).includes('--agent') &&
|
|
(call[1] as string[]).includes('opencode')
|
|
)
|
|
|
|
expect(skillsCall).toEqual([
|
|
'npm',
|
|
expect.arrayContaining(['--agent', 'opencode']),
|
|
expect.objectContaining({ cwd: '/workspace/create-app' }),
|
|
])
|
|
})
|
|
|
|
it('prints verbose command output when --verbose is set', async () => {
|
|
const { runCreateScreenciCli } = await import('./src/init.js')
|
|
|
|
await runCreateScreenciCli([
|
|
'node',
|
|
'create-screenci.js',
|
|
'--verbose',
|
|
'--yes',
|
|
])
|
|
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
"Running 'npm exec --yes --package=skills -- skills add screenci/screenci --skill screenci --skill playwright-cli -y'..."
|
|
)
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
"Running 'npm install --save-dev @playwright/test@^1.59.0 @types/node@^25.9.1 prettier@^3.6.0 @playwright/cli@latest react@^19.0.0 react-dom@^19.0.0 @types/react@^19.0.0 @types/react-dom@^19.0.0 vite@^7.0.0'..."
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('package manager detection', () => {
|
|
it('defaults to npm without a matching user agent', async () => {
|
|
const { determinePackageManager } = await import('./src/init.js')
|
|
|
|
expect(determinePackageManager()).toBe('npm')
|
|
})
|
|
|
|
it('detects pnpm from npm_config_user_agent', async () => {
|
|
const { determinePackageManager } = await import('./src/init.js')
|
|
|
|
process.env.npm_config_user_agent = 'pnpm/11.0.8 npm/? node/v24.0.0'
|
|
|
|
expect(determinePackageManager()).toBe('pnpm')
|
|
})
|
|
})
|
|
|
|
describe('playwright flag pass-through (test command)', () => {
|
|
beforeEach(() => {
|
|
process.env.SCREENCI_SECRET = 'test-secret'
|
|
})
|
|
|
|
it('should allow --fully-parallel to pass through', async () => {
|
|
process.argv = ['node', 'cli.js', 'test', '--fully-parallel']
|
|
mockSpawn.mockImplementation(
|
|
(
|
|
_command: string,
|
|
args: string[],
|
|
_options?: { env?: NodeJS.ProcessEnv }
|
|
) => {
|
|
expect(args).toContain('--fully-parallel')
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await main()
|
|
})
|
|
|
|
it('should allow --workers to pass through', async () => {
|
|
process.argv = ['node', 'cli.js', 'test', '--workers', '4']
|
|
mockSpawn.mockImplementation(
|
|
(
|
|
_command: string,
|
|
args: string[],
|
|
_options?: { env?: NodeJS.ProcessEnv }
|
|
) => {
|
|
expect(args).toContain('--workers')
|
|
expect(args).toContain('4')
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await main()
|
|
})
|
|
|
|
it('should allow --workers=N to pass through', async () => {
|
|
process.argv = ['node', 'cli.js', 'test', '--workers=4']
|
|
mockSpawn.mockImplementation(
|
|
(
|
|
_command: string,
|
|
args: string[],
|
|
_options?: { env?: NodeJS.ProcessEnv }
|
|
) => {
|
|
expect(args).toContain('--workers=4')
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await main()
|
|
})
|
|
|
|
it('should allow -j to pass through', async () => {
|
|
process.argv = ['node', 'cli.js', 'test', '-j', '4']
|
|
mockSpawn.mockImplementation(
|
|
(
|
|
_command: string,
|
|
args: string[],
|
|
_options?: { env?: NodeJS.ProcessEnv }
|
|
) => {
|
|
expect(args).toContain('-j')
|
|
expect(args).toContain('4')
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await main()
|
|
})
|
|
|
|
it('should allow -j=N to pass through', async () => {
|
|
process.argv = ['node', 'cli.js', 'test', '-j=4']
|
|
mockSpawn.mockImplementation(
|
|
(
|
|
_command: string,
|
|
args: string[],
|
|
_options?: { env?: NodeJS.ProcessEnv }
|
|
) => {
|
|
expect(args).toContain('-j=4')
|
|
process.nextTick(() => mockChildProcess.emit('close', 0))
|
|
return mockChildProcess as unknown as ChildProcess
|
|
}
|
|
)
|
|
|
|
const { main } = await import('./cli')
|
|
|
|
await main()
|
|
})
|
|
})
|
|
})
|