feat(cli): support local source installs

This commit is contained in:
Olli Paloviita
2026-05-13 10:10:55 +03:00
parent 8baa93cd2e
commit 0833d2cc3c
4 changed files with 229 additions and 65 deletions
+98 -30
View File
@@ -321,6 +321,12 @@ describe('CLI', () => {
process.nextTick(() => mockChildProcess.emit('close', 0))
return mockChildProcess as unknown as ChildProcess
})
mockReadFile.mockResolvedValue(
JSON.stringify({
name: 'project',
dependencies: { screenci: 'latest' },
})
)
const { main } = await import('./cli')
await main()
@@ -353,6 +359,37 @@ describe('CLI', () => {
expect(mockSpawn).toHaveBeenCalledTimes(1)
})
it('should install local screenci before tests when run through source cli', async () => {
delete process.env.SCREENCI_IN_CONTAINER
const sourceCliPath = `${process.cwd()}/cli.ts`
process.argv = ['node', sourceCliPath, 'test']
mockSpawn.mockReset()
mockSpawn.mockImplementation(() => {
process.nextTick(() => mockChildProcess.emit('close', 0))
return mockChildProcess as unknown as ChildProcess
})
const { main } = await import('./cli')
await main()
expect(mockSpawn).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({ cwd: process.cwd(), stdio: 'inherit' })
)
expect(mockSpawn).toHaveBeenCalledWith(
'npm',
['install', '--install-links'],
expect.objectContaining({ cwd: process.cwd(), stdio: 'inherit' })
)
expect(mockSpawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['playwright', 'test']),
expect.objectContaining({ stdio: 'inherit' })
)
})
it('should not show the record hint inside the container', async () => {
process.env.SCREENCI_IN_CONTAINER = 'true'
process.argv = ['node', 'cli.js', 'test']
@@ -1356,6 +1393,28 @@ describe('CLI', () => {
expect(pkgCall?.[1]).not.toContain('"screenci": "file:')
})
it('should use local screenci dependency when init runs through source cli', async () => {
const sourceCliPath = `${process.cwd()}/cli.ts`
process.argv = ['node', sourceCliPath, 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
const pkgCall = mockWriteFile.mock.calls.find(
(c: unknown[]) =>
typeof c[0] === 'string' && c[0].endsWith('package.json')
)
expect(pkgCall?.[1]).toContain('"screenci": "file:')
expect(pkgCall?.[1]).toContain('"screenci": "file:.."')
expect(pkgCall?.[1]).not.toContain('"screenci": "latest"')
expect(mockSpawn).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({ cwd: process.cwd(), stdio: 'inherit' })
)
})
it('should prompt for project name when not provided as arg', async () => {
process.argv = ['node', 'cli.js', 'init']
mockExistsSync.mockReturnValue(false)
@@ -1624,7 +1683,7 @@ describe('CLI', () => {
expect(mockSpawn).toHaveBeenCalledWith(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
expect.objectContaining({ stdio: 'pipe' })
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -1647,40 +1706,49 @@ describe('CLI', () => {
)
})
it('should show Chromium install output with init --verbose', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project', '--verbose']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
"Running 'npx playwright install chromium --with-deps'..."
)
})
it('should show a green Playwright success message after install', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project', '--verbose']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
`${pc.green('ok')} Playwright installed successfully`
)
})
it('should show spinner success for Playwright install', async () => {
it('should show Chromium install output during init', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
expect(mockOra).toHaveBeenCalledWith('Installing Playwright Chromium...')
expect(mockSpinner.succeed).toHaveBeenCalledWith(
'Playwright installed successfully'
expect(loggerInfoSpy).toHaveBeenCalledWith(
"Local development requires Chromium for Playwright, running 'npx playwright install chromium --with-deps'..."
)
expect(mockSpawn).toHaveBeenCalledWith(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('should show a green Playwright success message after install', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
`${pc.green('✔')} Playwright installed successfully`
)
})
it('should not hide Playwright install output behind a spinner', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
const { main } = await import('./cli')
await main()
expect(mockOra).not.toHaveBeenCalledWith(
'Installing Playwright Chromium...'
)
expect(mockSpawn).toHaveBeenCalledWith(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
expect.objectContaining({ stdio: 'inherit' })
)
})
@@ -1751,7 +1819,7 @@ describe('CLI', () => {
await main()
expect(loggerInfoSpy).toHaveBeenCalledWith(
'Local development requires Chromium for Playwright.'
"Local development requires Chromium for Playwright, running 'npx playwright install chromium --with-deps'..."
)
})
+98 -30
View File
@@ -1331,7 +1331,8 @@ export default defineConfig({
function generatePackageJson(
projectName: string,
includePlaywrightCli = false
includePlaywrightCli = false,
screenciDependency = 'latest'
): string {
const npmName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-')
const devDependencies: Record<string, string> = {
@@ -1354,7 +1355,7 @@ function generatePackageJson(
test: 'screenci test',
},
dependencies: {
screenci: 'latest',
screenci: screenciDependency,
},
devDependencies,
},
@@ -1534,6 +1535,65 @@ function getInitProjectRoot(): string {
return process.env['SCREENCI_INIT_CWD'] ?? process.cwd()
}
function isSourceCliEntrypoint(entrypoint: string | undefined): boolean {
return (
entrypoint?.endsWith('/cli.ts') === true ||
entrypoint?.endsWith('\\cli.ts') === true
)
}
function getDevScreenciPackageRoot(): string | undefined {
const explicitRoot = process.env.SCREENCI_DEV_PACKAGE_ROOT?.trim()
if (explicitRoot) return resolve(explicitRoot)
if (!isSourceCliEntrypoint(process.argv[1])) return undefined
return dirname(fileURLToPath(import.meta.url))
}
function getLocalScreenciDependency(
packageRoot: string,
projectDir: string
): string {
const relativePath = pathRelative(projectDir, packageRoot) || '.'
const normalizedPath = relativePath.replace(/\\/g, '/')
return `file:${normalizedPath.startsWith('.') ? normalizedPath : `./${normalizedPath}`}`
}
async function buildLocalScreenciPackage(packageRoot: string): Promise<void> {
logger.info(`Using local screenci package: ${packageRoot}`)
logger.info("Running 'npm run build' for local screenci package...")
await spawnInherited(
'npm',
['run', 'build'],
packageRoot,
'screenci dev build'
)
}
async function installLocalScreenciPackage(
projectDir: string,
packageRoot: string
): Promise<void> {
const packageJsonPath = resolve(projectDir, 'package.json')
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as {
dependencies?: Record<string, string>
}
packageJson.dependencies = {
...packageJson.dependencies,
screenci: getLocalScreenciDependency(packageRoot, projectDir),
}
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`)
logger.info('Installing local screenci package into this project...')
await spawnInherited(
'npm',
['install', '--install-links'],
projectDir,
'screenci dev install'
)
}
function buildChildEnv(): NodeJS.ProcessEnv {
const { PATH, HOME, USER, LOGNAME, TMPDIR, TEMP, TMP } = process.env
return {
@@ -1552,6 +1612,7 @@ function buildChildEnv(): NodeJS.ProcessEnv {
SCREENCI_IN_CONTAINER: process.env.SCREENCI_IN_CONTAINER,
SCREENCI_RECORD: process.env.SCREENCI_RECORD,
SCREENCI_SIGNAL_LOGGING: process.env.SCREENCI_SIGNAL_LOGGING,
SCREENCI_DEV_PACKAGE_ROOT: process.env.SCREENCI_DEV_PACKAGE_ROOT,
}
}
@@ -1658,6 +1719,14 @@ async function runInit(
'-y',
]
const skillsCommand = `npx ${skillsArgs.join(' ')}`
const devScreenciPackageRoot = getDevScreenciPackageRoot()
const screenciDependency = devScreenciPackageRoot
? getLocalScreenciDependency(devScreenciPackageRoot, projectDir)
: 'latest'
if (devScreenciPackageRoot) {
await buildLocalScreenciPackage(devScreenciPackageRoot)
}
await mkdir(resolve(projectDir, 'videos'), { recursive: true })
await mkdir(resolve(projectDir, '.github', 'workflows'), { recursive: true })
@@ -1667,7 +1736,7 @@ async function runInit(
)
await writeFile(
resolve(projectDir, 'package.json'),
generatePackageJson(dirName, shouldAddPlaywrightCli)
generatePackageJson(dirName, shouldAddPlaywrightCli, screenciDependency)
)
await writeFile(resolve(projectDir, 'README.md'), generateReadme(projectName))
await writeFile(resolve(projectDir, 'Dockerfile'), generateDockerfile())
@@ -1709,12 +1778,18 @@ async function runInit(
}
if (verbose) {
logger.info("Running 'npm install'...")
await spawnInherited('npm', ['install'], projectDir, 'screenci init')
const installArgs = devScreenciPackageRoot
? ['install', '--install-links']
: ['install']
logger.info(`Running 'npm ${installArgs.join(' ')}'...`)
await spawnInherited('npm', installArgs, projectDir, 'screenci init')
} else {
const spinner = ora('Running npm install...').start()
try {
await spawnSilent('npm', ['install', '--prefix', projectDir])
const installArgs = devScreenciPackageRoot
? ['install', '--install-links', '--prefix', projectDir]
: ['install', '--prefix', projectDir]
await spawnSilent('npm', installArgs)
spinner.succeed('npm install complete')
} catch (err) {
spinner.fail('npm install failed')
@@ -1722,30 +1797,16 @@ async function runInit(
}
}
logger.info('Local development requires Chromium for Playwright.')
if (verbose) {
logger.info("Running 'npx playwright install chromium --with-deps'...")
await spawnInherited(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
projectDir,
'screenci init'
)
logger.info(`${pc.green('ok')} Playwright installed successfully`)
} else {
const spinner = ora('Installing Playwright Chromium...').start()
try {
await spawnSilent(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
projectDir
)
spinner.succeed('Playwright installed successfully')
} catch (err) {
spinner.fail('Playwright install failed')
throw err
}
}
logger.info(
"Local development requires Chromium for Playwright, running 'npx playwright install chromium --with-deps'..."
)
await spawnInherited(
'npx',
['playwright', 'install', 'chromium', '--with-deps'],
projectDir,
'screenci init'
)
logger.info(`${pc.green('')} Playwright installed successfully`)
const cliDir = dirname(fileURLToPath(import.meta.url))
await buildRecordImages(
requireContainerRuntime(),
@@ -2456,6 +2517,13 @@ async function run(
logger.info(`Using config: ${configPath}`)
}
const devScreenciPackageRoot = getDevScreenciPackageRoot()
if (devScreenciPackageRoot) {
const configDir = dirname(configPath)
await buildLocalScreenciPackage(devScreenciPackageRoot)
await installLocalScreenciPackage(configDir, devScreenciPackageRoot)
}
const playwrightArgs = [
'playwright',
'test',
+28
View File
@@ -190,6 +190,34 @@ describe('mouse helpers', () => {
expect(getMousePosition(page)).toEqual({ x: 12, y: 34 })
})
it('reads locator bounds before the real click', async () => {
const page = {}
const calls: string[] = []
const locator = {
page: vi.fn().mockReturnValue(page),
boundingBox: vi.fn().mockImplementation(async () => {
calls.push('boundingBox')
return { x: 10, y: 20, width: 30, height: 40 }
}),
} as unknown as Locator
const mouseClickInternal = vi.fn().mockImplementation(async () => {
calls.push('click')
})
const promise = performMouseClickAction({
locator,
doClick: mouseClickInternal,
supportsTrial: false,
targetX: 12,
targetY: 34,
})
await vi.runAllTimersAsync()
await promise
expect(calls).toEqual(['boundingBox', 'click'])
})
it('supports tripleBefore click mode', async () => {
const page = {}
const locator = {
+5 -5
View File
@@ -409,6 +409,11 @@ export async function performMouseClickAction(
})
}
const elementRect = await options.locator.boundingBox()
if (!elementRect) {
logger.warn('[screenci] Unable to resolve locator bounds before action.')
}
if (mode === 'tripleBefore') {
for (let i = 0; i < 3; i++) {
const startMs = Date.now()
@@ -495,11 +500,6 @@ export async function performMouseClickAction(
)
}
const elementRect = await options.locator.boundingBox()
if (!elementRect) {
logger.warn('[screenci] Unable to resolve locator bounds after action.')
}
setMousePosition(page, { x: options.targetX, y: options.targetY })
return {