mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat(cli): rewrite loopback base urls in containers
This commit is contained in:
+26
-7
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -703,6 +703,7 @@ export type ScreenCIConfig = Omit<
|
||||
| 'retries'
|
||||
| 'testDir'
|
||||
| 'testMatch'
|
||||
| 'webServer'
|
||||
| 'use'
|
||||
| 'projects'
|
||||
> & {
|
||||
|
||||
Reference in New Issue
Block a user