fix(cli): guide users to cd into screenci island instead of auto-using its config

Resolve config by walking up for a flat screenci.config.ts only. When the
command runs from outside the island and a nested screenci/screenci.config.ts
is found, exit with guidance to cd into it, rather than silently running a
registry-downloaded CLI version. Run smoke-test CI steps from inside the
island so they exercise the local build.
This commit is contained in:
Olli Paloviita
2026-06-14 13:40:40 +03:00
parent c1b7a4ce12
commit e92bc0f37e
6 changed files with 127 additions and 59 deletions
+6 -6
View File
@@ -125,7 +125,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} node ${{ github.workspace }}/bin/screenci.js init smoke-project --package-manager npm --yes --verbose
- name: Run screenci test with npm
working-directory: ${{ env.SMOKE_DIRECT_NPM }}
working-directory: ${{ env.SMOKE_DIRECT_NPM }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} npx screenci test
- name: Run screenci init with pnpm
@@ -133,7 +133,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} node ${{ github.workspace }}/bin/screenci.js init smoke-project --package-manager pnpm --yes --verbose
- name: Run screenci test with pnpm
working-directory: ${{ env.SMOKE_DIRECT_PNPM }}
working-directory: ${{ env.SMOKE_DIRECT_PNPM }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} pnpm exec screenci test
- name: Run create-screenci with npm
@@ -141,7 +141,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} npm exec --yes --package=${{ env.CREATE_SCREENCI_DEPENDENCY }} --package=${{ env.SCREENCI_INIT_SCREENCI_DEPENDENCY }} -- create-screenci smoke-project --package-manager npm --yes --verbose
- name: Run generated screenci test with npm
working-directory: ${{ env.SMOKE_CREATE_NPM }}
working-directory: ${{ env.SMOKE_CREATE_NPM }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} npx screenci test
- name: Run create-screenci with pnpm
@@ -149,7 +149,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} pnpm dlx --package=${{ env.CREATE_SCREENCI_DEPENDENCY }} --package=${{ env.SCREENCI_INIT_SCREENCI_DEPENDENCY }} create-screenci smoke-project --package-manager pnpm --yes --verbose
- name: Run generated screenci test with pnpm
working-directory: ${{ env.SMOKE_CREATE_PNPM }}
working-directory: ${{ env.SMOKE_CREATE_PNPM }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} pnpm exec screenci test
- name: Run screenci init with yarn
@@ -157,7 +157,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} node ${{ github.workspace }}/bin/screenci.js init smoke-project --package-manager yarn --yes --verbose
- name: Run screenci test with yarn
working-directory: ${{ env.SMOKE_DIRECT_YARN }}
working-directory: ${{ env.SMOKE_DIRECT_YARN }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} yarn screenci test
- name: Create pnpm workspace scaffold
@@ -175,7 +175,7 @@ jobs:
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} node ${{ github.workspace }}/bin/screenci.js init smoke-project --yes --verbose
- name: Run screenci test in pnpm workspace
working-directory: ${{ env.SMOKE_PNPM_WORKSPACE }}
working-directory: ${{ env.SMOKE_PNPM_WORKSPACE }}/screenci
run: node ${{ github.workspace }}/scripts/ci-run-in-shell.js ${{ matrix.shell_name }} pnpm exec screenci test
# pnpm 10 and 11 ignore dependency build scripts unless they are approved, and
+4
View File
@@ -373,6 +373,10 @@ describe('CLI', () => {
it('bootstraps auth interactively when SCREENCI_SECRET is missing', async () => {
delete process.env.SCREENCI_SECRET
// Force an interactive session so record runs the blocking browser flow.
// detectInteractiveSession() also treats CI=true and SCREENCI_NONINTERACTIVE
// as non-interactive, so clear them (GitHub Actions sets CI=true).
delete process.env.CI
delete process.env.SCREENCI_NONINTERACTIVE
const originalStdoutTTY = process.stdout.isTTY
const originalStdinTTY = process.stdin.isTTY
process.stdout.isTTY = true
+13 -8
View File
@@ -401,7 +401,7 @@ describe('CLI', () => {
}
})
it('resolves the island config when run from the repo root', async () => {
it('does not auto-use the island config from the repo root, and guides cd screenci', async () => {
process.argv = ['node', 'cli.js', 'test']
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/repo')
mockExistsSync.mockImplementation(
@@ -410,16 +410,20 @@ describe('CLI', () => {
mockSpawnCloseZero()
try {
const { main } = await import('./cli')
await main()
expect(findResolvedConfigArg()).toBe(
'/repo/screenci/screenci.config.ts'
await expect(main()).rejects.toThrow('process.exit called')
// The island config must not be silently used: running from outside the
// island resolves the `screenci` binary from the registry, not the
// version-pinned island install.
expect(findResolvedConfigArg()).toBeUndefined()
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('cd screenci')
)
} finally {
cwdSpy.mockRestore()
}
})
it('walks up from a nested directory to find the island config', async () => {
it('guides cd into a nested island found while walking up parents', async () => {
process.argv = ['node', 'cli.js', 'test']
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/repo/apps/web')
mockExistsSync.mockImplementation(
@@ -428,9 +432,10 @@ describe('CLI', () => {
mockSpawnCloseZero()
try {
const { main } = await import('./cli')
await main()
expect(findResolvedConfigArg()).toBe(
'/repo/screenci/screenci.config.ts'
await expect(main()).rejects.toThrow('process.exit called')
expect(findResolvedConfigArg()).toBeUndefined()
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('cd ../../screenci')
)
} finally {
cwdSpy.mockRestore()
+92 -39
View File
@@ -918,6 +918,28 @@ function resolveWindowsCmdShim(cmd: string): string {
return shimName
}
function isModuleNotFoundError(error: unknown): boolean {
return (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND'
)
}
function resolvePlaywrightCliEntrypoint(searchFrom: string): string {
// Prefer the @playwright/test installed alongside the user's config, since it
// is declared as a peer dependency of the project being recorded.
try {
return require.resolve('@playwright/test/cli', { paths: [searchFrom] })
} catch (error) {
if (!isModuleNotFoundError(error)) throw error
}
// Fall back to the copy resolvable from the screenci CLI's own install. This
// keeps discovery working when Playwright is hoisted to a parent install or
// bundled with the CLI rather than next to the config file.
return require.resolve('@playwright/test/cli')
}
function resolvePlaywrightSpawnSpec(
args: string[],
searchFrom: string
@@ -927,9 +949,7 @@ function resolvePlaywrightSpawnSpec(
shell?: boolean
windowsVerbatimArguments?: boolean
} {
const cliEntrypoint = require.resolve('@playwright/test/cli', {
paths: [searchFrom],
})
const cliEntrypoint = resolvePlaywrightCliEntrypoint(searchFrom)
return {
command: process.execPath,
@@ -1025,29 +1045,39 @@ function clearRecordingDirectories(dir: string): void {
}
}
function findScreenCIConfig(customPath?: string): string | null {
type ScreenCIConfigResolution =
| { kind: 'found'; path: string }
| { kind: 'island-not-entered'; islandConfigPath: string }
| { kind: 'not-found' }
function findScreenCIConfig(customPath?: string): ScreenCIConfigResolution {
if (customPath) {
const resolvedPath = resolve(process.cwd(), customPath)
if (existsSync(resolvedPath)) {
return resolvedPath
}
return null
return existsSync(resolvedPath)
? { kind: 'found', path: resolvedPath }
: { kind: 'not-found' }
}
// Walk up from the current directory so `screenci test`/`record` work both
// inside the `screenci/` island and from the repo root (or any directory in
// between). At each level we prefer a flat `screenci.config.ts`, then fall
// back to the island layout `screenci/screenci.config.ts`.
// Walk up from the current directory looking for a flat `screenci.config.ts`,
// which is what's present when the command runs from inside the `screenci/`
// island. We deliberately do NOT auto-use a nested
// `screenci/screenci.config.ts`: running the CLI from outside the island
// resolves the `screenci` binary from the registry (npx download) rather than
// the version-pinned island install, so it would silently run a different
// version. Instead we detect the island and ask the user to `cd` into it.
let current = process.cwd()
let islandConfigPath: string | undefined
while (true) {
const flatConfig = resolve(current, 'screenci.config.ts')
if (existsSync(flatConfig)) {
return flatConfig
return { kind: 'found', path: flatConfig }
}
const islandConfig = resolve(current, 'screenci', 'screenci.config.ts')
if (existsSync(islandConfig)) {
return islandConfig
if (islandConfigPath === undefined) {
const islandConfig = resolve(current, 'screenci', 'screenci.config.ts')
if (existsSync(islandConfig)) {
islandConfigPath = islandConfig
}
}
const parent = dirname(current)
@@ -1055,7 +1085,39 @@ function findScreenCIConfig(customPath?: string): string | null {
current = parent
}
return null
if (islandConfigPath !== undefined) {
return { kind: 'island-not-entered', islandConfigPath }
}
return { kind: 'not-found' }
}
// Resolve the config path, or log a helpful message and exit. Centralizes the
// `cd screenci` guidance so every command (test/record/info/...) behaves the
// same when invoked from outside the island.
function resolveScreenCIConfigPathOrExit(customPath?: string): string {
const resolution = findScreenCIConfig(customPath)
switch (resolution.kind) {
case 'found':
return resolution.path
case 'island-not-entered': {
const islandDir = dirname(resolution.islandConfigPath)
const relDir = pathRelative(process.cwd(), islandDir) || '.'
logger.error(
`Error: no screenci.config.ts found here, but found ${pc.cyan(
`${relDir}/screenci.config.ts`
)}. Run ${pc.cyan(`cd ${relDir}`)} and rerun the command from there.`
)
return process.exit(1)
}
case 'not-found': {
logger.error(
customPath
? `Error: Config file not found: ${customPath}`
: 'Error: screenci.config.ts not found in the current directory or any parent.'
)
return process.exit(1)
}
}
}
async function hashFile(filePath: string): Promise<string> {
@@ -1697,14 +1759,7 @@ async function loadScreenCIConfigAndEnv(configPath?: string): Promise<{
resolvedConfigPath: string
screenciConfig: ScreenCIConfig
}> {
const resolvedConfigPath = findScreenCIConfig(configPath)
if (!resolvedConfigPath) {
const errorMsg = configPath
? `Error: Config file not found: ${configPath}`
: 'Error: screenci.config.ts not found in current directory'
logger.error(errorMsg)
process.exit(1)
}
const resolvedConfigPath = resolveScreenCIConfigPathOrExit(configPath)
let screenciConfig: ScreenCIConfig
try {
@@ -2392,9 +2447,12 @@ export async function main() {
if (process.env.SCREENCI_RECORDING === 'true') return
// After recording, upload results to API if configured
const resolvedConfigPath = findScreenCIConfig(parsed.configPath)
if (resolvedConfigPath) {
// After recording, upload results to API if configured. `run` already
// resolved the config (or exited), so this best-effort lookup only acts
// when a flat config is present in/under the current directory.
const resolution = findScreenCIConfig(parsed.configPath)
if (resolution.kind === 'found') {
const resolvedConfigPath = resolution.path
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
@@ -2549,8 +2607,11 @@ export async function main() {
const parsed = parseConfigCliArgs(getSubcommandArgv('test'))
let configMockRecord = false
const resolvedConfigPath = findScreenCIConfig(parsed.configPath)
if (resolvedConfigPath) {
// Best-effort env preload before handing off to `run`, which performs the
// authoritative resolution (and emits the `cd screenci` guidance on miss).
const resolution = findScreenCIConfig(parsed.configPath)
if (resolution.kind === 'found') {
const resolvedConfigPath = resolution.path
try {
const screenciConfig =
await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath)
@@ -2788,15 +2849,7 @@ async function run(
verbose = false,
mockRecord = false
) {
const configPath = findScreenCIConfig(customConfigPath)
if (!configPath) {
const errorMsg = customConfigPath
? `Error: Config file not found: ${customConfigPath}`
: 'Error: screenci.config.ts not found in the current directory or any parent (looked for ./screenci.config.ts and ./screenci/screenci.config.ts)'
logger.error(errorMsg)
process.exit(1)
}
const configPath = resolveScreenCIConfigPathOrExit(customConfigPath)
if (command === 'test' || process.env.SCREENCI_RECORDING !== 'true') {
await loadEnvFileFromConfigSource(configPath, false)
+4 -3
View File
@@ -3,9 +3,10 @@
import { Tabs, TabItem } from '@astrojs/starlight/components'
The `screenci` CLI keeps the workflow small: initialize a project, iterate
locally, record final output, and manage public delivery when needed. Most
commands resolve `screenci.config.ts` from the current directory unless you
pass `--config <path>`.
locally, record final output, and manage public delivery when needed. Run
commands from inside your `screenci/` project directory. Most commands resolve
`screenci.config.ts` from the current directory unless you pass
`--config <path>`.
## Command overview
+8 -3
View File
@@ -127,9 +127,14 @@ video('How to find docs', async ({ page }) => {
## Run the example
Test the starter video locally. The `screenci` commands work both from inside
the `screenci/` directory and from the repository root. ScreenCI walks up to
find `screenci/screenci.config.ts` automatically:
All `screenci` commands run from inside the `screenci/` directory, so switch
into it first:
```bash
cd screenci
```
Then test the starter video locally:
<Tabs syncKey="package-manager">
<TabItem label="npm">