mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user