feat(cli): rewrite loopback base urls in containers

This commit is contained in:
Olli Paloviita
2026-05-15 15:52:25 +03:00
parent c12a1515b0
commit ba0b849411
7 changed files with 188 additions and 51 deletions
+26 -7
View File
@@ -465,6 +465,23 @@ describe('CLI', () => {
expect(runArgs).toContain('CI=true')
})
it('should pass Podman host alias env to the container', async () => {
process.argv = ['node', 'cli.js', 'record']
const { main } = await import('./cli')
const mainPromise = main()
await driveContainerSpawns()
await mainPromise
const runArgs = mockSpawn.mock.calls[0][1] as string[]
expect(runArgs).toContain(
'SCREENCI_CONTAINER_BASE_HOST=host.containers.internal'
)
})
it('should not load env file when recording on CI', async () => {
process.env.CI = 'true'
process.argv = ['node', 'cli.js', 'record']
@@ -580,6 +597,12 @@ describe('CLI', () => {
await mainPromise
expect(mockSpawnSync).toHaveBeenCalledTimes(2)
const runArgs = mockSpawn.mock.calls[0]?.[1] as string[]
expect(runArgs).toContain('--add-host')
expect(runArgs).toContain('host.docker.internal:host-gateway')
expect(runArgs).toContain(
'SCREENCI_CONTAINER_BASE_HOST=host.docker.internal'
)
})
it('should clear and recreate .screenci directory before running container', async () => {
@@ -1933,13 +1956,11 @@ describe('CLI', () => {
expect(configCall?.[1]).toContain("envFile: '.env'")
})
it('should include baseURL and webServer for a local development target', async () => {
it('should include baseURL for a local development target', async () => {
process.argv = ['node', 'cli.js', 'init', 'my-project']
mockExistsSync.mockReturnValue(false)
mockSelect.mockResolvedValueOnce('local')
mockInput
.mockResolvedValueOnce('http://localhost:4173')
.mockResolvedValueOnce('pnpm run preview')
mockInput.mockResolvedValueOnce('http://localhost:4173')
const { main } = await import('./cli')
await main()
@@ -1949,9 +1970,7 @@ describe('CLI', () => {
typeof c[0] === 'string' && c[0].endsWith('screenci.config.ts')
)
expect(configCall?.[1]).toContain('baseURL: "http://localhost:4173/"')
expect(configCall?.[1]).toContain('command: "cd .. && pnpm run preview"')
expect(configCall?.[1]).toContain('url: "http://localhost:4173/"')
expect(configCall?.[1]).toContain('reuseExistingServer: true')
expect(configCall?.[1]).not.toContain('webServer: {')
})
it('should include only baseURL for a public target', async () => {
+15 -31
View File
@@ -56,7 +56,6 @@ type InitTargetMode = 'local' | 'public'
type InitTarget = {
mode: InitTargetMode
baseURL: string
webServerCommand?: string
}
function resolveRecordingFileCandidates(
@@ -1339,15 +1338,6 @@ function generateConfig(projectName: string, initTarget?: InitTarget): string {
? ` baseURL: ${JSON.stringify(initTarget.baseURL)},
`
: ''
const webServerBlock =
initTarget?.mode === 'local' && initTarget.webServerCommand
? ` webServer: {
command: ${JSON.stringify(`cd .. && ${initTarget.webServerCommand}`)},
url: ${JSON.stringify(initTarget.baseURL)},
reuseExistingServer: true,
},
`
: ''
return `import { defineConfig } from 'screenci'
@@ -1362,7 +1352,7 @@ ${baseURLBlock} recordOptions: {
fps: 30,
},
},
${webServerBlock} projects: [
projects: [
{
name: 'chromium',
},
@@ -1476,6 +1466,7 @@ function generateDockerfile(): string {
return `FROM ghcr.io/screenci/record:latest
COPY package.json ./
RUN npm install
COPY screenci.config.ts ./
COPY videos ./videos
`
@@ -1703,13 +1694,6 @@ async function promptInitTargetUrl(mode: InitTargetMode): Promise<string> {
})
}
async function promptInitWebServerCommand(): Promise<string> {
return input({
message: 'Command to start your development server:',
default: 'npm run dev',
})
}
function normalizeInitUrl(url: string): string {
try {
return new URL(url.trim()).toString()
@@ -1908,19 +1892,10 @@ async function runInit(
: await (async (): Promise<InitTarget> => {
const mode = await promptInitTargetMode()
const baseURL = normalizeInitUrl(await promptInitTargetUrl(mode))
const webServerCommand =
mode === 'local' ? await promptInitWebServerCommand() : undefined
return webServerCommand
? {
mode,
baseURL,
webServerCommand,
}
: {
mode,
baseURL,
}
return {
mode,
baseURL,
}
})()
const shouldInstallDependencies = yes
@@ -2587,13 +2562,22 @@ async function runWithContainer(
process.exit(1)
}
logger.info('Starting ScreenCI recording container...')
const containerBaseHost =
containerRuntime === 'docker'
? 'host.docker.internal'
: 'host.containers.internal'
await spawnContainerRecording(containerRuntime, [
'run',
'--rm',
...(containerRuntime === 'docker'
? ['--add-host', 'host.docker.internal:host-gateway']
: []),
...(process.env.CI !== undefined ? ['-e', `CI=${process.env.CI}`] : []),
'-e',
'SCREENCI_IN_CONTAINER=true',
'-e',
`SCREENCI_CONTAINER_BASE_HOST=${containerBaseHost}`,
'-e',
'SCREENCI_RECORD=true',
'-e',
`SCREENCI_SECRET=${secret}`,
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
})
```
Any other valid Playwright config options (e.g. `timeout`, `reporter`, `webServer`) are passed through.
Any other valid Playwright config options (e.g. `timeout`, `reporter`) are passed through, except options ScreenCI manages itself.
---
-7
View File
@@ -44,13 +44,6 @@ export default defineConfig({
// Any other Playwright 'use' options work here
baseURL: 'https://staging.example.com',
},
// Playwright's webServer option works as normal
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
```
+73
View File
@@ -176,6 +176,79 @@ describe('defineConfig', () => {
expect(config.use?.baseURL).toBe('https://app.example.com')
})
it('should rewrite localhost baseURL inside the recording container', () => {
const originalContainer = process.env.SCREENCI_IN_CONTAINER
const originalBaseHost = process.env.SCREENCI_CONTAINER_BASE_HOST
process.env.SCREENCI_IN_CONTAINER = 'true'
process.env.SCREENCI_CONTAINER_BASE_HOST = 'host.docker.internal'
try {
const config = defineConfig({
projectName: 'Test',
use: {
baseURL: 'http://localhost:4321/',
},
})
expect(config.use?.baseURL).toBe('http://host.docker.internal:4321/')
} finally {
if (originalContainer === undefined) {
delete process.env.SCREENCI_IN_CONTAINER
} else {
process.env.SCREENCI_IN_CONTAINER = originalContainer
}
if (originalBaseHost === undefined) {
delete process.env.SCREENCI_CONTAINER_BASE_HOST
} else {
process.env.SCREENCI_CONTAINER_BASE_HOST = originalBaseHost
}
}
})
it('should rewrite localhost baseURL in project use inside the recording container', () => {
const originalContainer = process.env.SCREENCI_IN_CONTAINER
process.env.SCREENCI_IN_CONTAINER = 'true'
try {
const config = defineConfig({
projectName: 'Test',
projects: [
{
name: 'chromium',
use: {
baseURL: 'http://127.0.0.1:3000/',
},
},
],
})
expect(config.projects?.[0]?.use?.baseURL).toBe(
'http://host.containers.internal:3000/'
)
} finally {
if (originalContainer === undefined) {
delete process.env.SCREENCI_IN_CONTAINER
} else {
process.env.SCREENCI_IN_CONTAINER = originalContainer
}
}
})
it('should reject webServer option', () => {
expect(() => {
defineConfig({
projectName: 'Test',
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
},
})
}).toThrow('screenci does not support "webServer" option')
})
it('should accept recordOptions in project use', () => {
const config = defineConfig({
projectName: 'Test',
+72 -5
View File
@@ -23,13 +23,64 @@ const reporterPath = existsSync(reporterPathJs)
type ReporterConfig = string | ReporterDescription
function rewriteLoopbackBaseUrl(
baseURL: string | undefined
): string | undefined {
if (
process.env.SCREENCI_IN_CONTAINER !== 'true' ||
typeof baseURL !== 'string'
) {
return baseURL
}
let parsedUrl: URL
try {
parsedUrl = new URL(baseURL)
} catch {
return baseURL
}
if (
parsedUrl.hostname !== 'localhost' &&
parsedUrl.hostname !== '127.0.0.1'
) {
return baseURL
}
parsedUrl.hostname =
process.env.SCREENCI_CONTAINER_BASE_HOST ?? 'host.containers.internal'
return parsedUrl.toString()
}
function rewriteUseBaseUrl<T extends object>(
use: T | undefined
): T | undefined {
if (!use) return use
const currentBaseURL = (use as { baseURL?: unknown }).baseURL
if (typeof currentBaseURL !== 'string') {
return use
}
const rewrittenBaseURL = rewriteLoopbackBaseUrl(currentBaseURL)
if (rewrittenBaseURL === currentBaseURL) {
return use
}
return {
...use,
baseURL: rewrittenBaseURL,
} as T
}
/**
* Defines a screenci configuration file.
*
* Extends Playwright's config with screenci-specific options and enforces
* settings required for reliable video recording. Some Playwright options
* are locked and cannot be set `workers`, `fullyParallel`, `retries`,
* `testDir`, and `testMatch`. Attempting to set them throws at startup.
* `testDir`, `testMatch`, and `webServer`. Attempting to set them throws at startup.
*
* @example
* Minimal all options have sensible defaults:
@@ -140,6 +191,13 @@ export function defineConfig(config: ScreenCIConfig): ExtendedScreenCIConfig {
)
}
if ('webServer' in config) {
throw new Error(
'screenci does not support "webServer" option. ' +
'Start your app separately before running screenci test or screenci record.'
)
}
// Runtime check for workers
if ('workers' in config) {
throw new Error(
@@ -168,19 +226,28 @@ export function defineConfig(config: ScreenCIConfig): ExtendedScreenCIConfig {
}
const { videoDir, ...rest } = config
const normalizedUse = rewriteUseBaseUrl(rest.use)
const normalizedProjects = rest.projects?.map((project) => {
const normalizedProjectUse = rewriteUseBaseUrl(project.use)
return normalizedProjectUse
? { ...project, use: normalizedProjectUse }
: project
})
// Force sequential execution with single worker and no retries, map videoDir to testDir
return {
testDir: videoDir ?? DEFAULT_VIDEO_DIR,
testMatch: '**/*.video.?(c|m)[jt]s?(x)',
...rest,
...(normalizedProjects ? { projects: normalizedProjects } : {}),
reporter: reporters as ReporterDescription[],
use: {
...rest.use,
trace: rest.use?.trace ?? DEFAULT_TRACE,
actionTimeout: rest.use?.actionTimeout ?? DEFAULT_ACTION_TIMEOUT,
...normalizedUse,
trace: normalizedUse?.trace ?? DEFAULT_TRACE,
actionTimeout: normalizedUse?.actionTimeout ?? DEFAULT_ACTION_TIMEOUT,
navigationTimeout:
rest.use?.navigationTimeout ?? DEFAULT_NAVIGATION_TIMEOUT,
normalizedUse?.navigationTimeout ?? DEFAULT_NAVIGATION_TIMEOUT,
},
timeout: rest.timeout ?? DEFAULT_TIMEOUT,
fullyParallel: false,
+1
View File
@@ -703,6 +703,7 @@ export type ScreenCIConfig = Omit<
| 'retries'
| 'testDir'
| 'testMatch'
| 'webServer'
| 'use'
| 'projects'
> & {