mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
4cbdbc47f3
One source model: the scripts live wherever the agent ran, ScreenCI keeps a snapshot uploaded with every preview and export (opt out with uploadSources: false). `screenci setup <code>` uses an existing workspace as is (--force replaces it with the snapshot), pulls the snapshot into an absent one, or scaffolds a new project. The repository clone under .screenci/repo is read-only context; the workspace never lives in it. Removed: `screenci merge-complete`, .screenci/pending-merge.json, the merge setup-code kind, sourceMode/sourcesUnmerged in the exchange, SCREENCI_UPLOAD_SOURCES, stripIslandProjectId, and the clone-workspace and merge-prepared outcomes. No `start` alias: breaking change on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1719 lines
58 KiB
TypeScript
1719 lines
58 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { Command } from 'commander'
|
|
import { stripVTControlCharacters } from 'node:util'
|
|
import {
|
|
StartError,
|
|
exchangeSetupCode,
|
|
findVideoSourceFile,
|
|
formatStartBrief,
|
|
formatStartJsonLine,
|
|
registerSetupCommand,
|
|
resolveStartWorkspace,
|
|
runSetupCommand,
|
|
type SetupExchange,
|
|
siteRootOf,
|
|
type StartDeps,
|
|
type StartResult,
|
|
} from './src/setup.js'
|
|
import type { SourceBundleFs } from './src/sourceBundle.js'
|
|
import type { StartGit } from './src/repo.js'
|
|
import { EMPTY_AI_CONTEXT } from './src/aiContext.js'
|
|
import type { AppSessionStatus } from './src/appSession.js'
|
|
import {
|
|
EMPTY_BRANDING,
|
|
type DownloadBrandingSampleResult,
|
|
} from './src/branding.js'
|
|
|
|
function jsonResponse(
|
|
body: unknown,
|
|
status = 200,
|
|
headers: Record<string, string> = {}
|
|
): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
})
|
|
}
|
|
|
|
const CODE = 'SC-ABCD-EFGH'
|
|
const API = 'https://api.example.com'
|
|
|
|
function exchange(overrides: Partial<SetupExchange> = {}): SetupExchange {
|
|
return {
|
|
kind: 'project',
|
|
orgId: 'org_1',
|
|
projectId: 'proj_1',
|
|
projectName: 'my-app',
|
|
secret: 'secret-1',
|
|
task: { description: 'Show the onboarding flow' },
|
|
sourcesAvailable: false,
|
|
ciRecords: false,
|
|
appUrl: 'https://app.example.com',
|
|
aiContext: EMPTY_AI_CONTEXT,
|
|
branding: EMPTY_BRANDING,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const ACME_GIT = 'https://github.com/acme/app.git'
|
|
|
|
/** Server-side exchange payload (no `ok`, `appUrl` may be null). */
|
|
function exchangeBody(overrides: Partial<SetupExchange> = {}) {
|
|
return exchange(overrides)
|
|
}
|
|
|
|
/** In-memory fs keyed by absolute paths; directories are implied by files. */
|
|
function memoryFs(seed: Record<string, string> = {}) {
|
|
const files = new Map<string, string>(Object.entries(seed))
|
|
const fs: SourceBundleFs = {
|
|
readdir: async (dir) => {
|
|
const prefix = `${dir}/`
|
|
const names = new Map<string, boolean>()
|
|
for (const path of files.keys()) {
|
|
if (!path.startsWith(prefix)) continue
|
|
const [head, ...tail] = path.slice(prefix.length).split('/')
|
|
if (head) names.set(head, tail.length > 0)
|
|
}
|
|
if (names.size === 0) throw new Error('ENOENT')
|
|
return [...names.entries()].map(([name, isDir]) => ({
|
|
name,
|
|
isDirectory: () => isDir,
|
|
isFile: () => !isDir,
|
|
}))
|
|
},
|
|
readFile: async (path) => {
|
|
const content = files.get(path)
|
|
if (content === undefined) throw new Error(`ENOENT ${path}`)
|
|
return Buffer.from(content)
|
|
},
|
|
writeFile: async (path, data) => {
|
|
files.set(path, data)
|
|
},
|
|
mkdir: async () => undefined,
|
|
exists: async (path) =>
|
|
files.has(path) ||
|
|
[...files.keys()].some((key) => key.startsWith(`${path}/`)),
|
|
}
|
|
const existsSync = (path: string): boolean =>
|
|
files.has(path) ||
|
|
[...files.keys()].some((key) => key.startsWith(`${path}/`))
|
|
return { fs, files, existsSync }
|
|
}
|
|
|
|
function makeDeps(
|
|
fetchFn: (
|
|
input: string | URL | Request,
|
|
init?: RequestInit
|
|
) => Promise<Response>,
|
|
seed: Record<string, string> = {}
|
|
) {
|
|
const mem = memoryFs(seed)
|
|
const logs: string[] = []
|
|
const warnings: string[] = []
|
|
const calls = {
|
|
scaffold: [] as Array<Record<string, unknown>>,
|
|
install: [] as string[],
|
|
shell: [] as string[],
|
|
skills: [] as Array<Record<string, unknown>>,
|
|
secrets: [] as Array<[string, string]>,
|
|
clones: [] as Array<[string, string]>,
|
|
updates: [] as string[],
|
|
probes: [] as string[],
|
|
sessionReads: [] as Array<{ configDir: string; profile: string }>,
|
|
sampleDownloads: [] as string[],
|
|
}
|
|
let sampleDownload: DownloadBrandingSampleResult = { status: 'none' }
|
|
/** Remotes by directory; set by tests that simulate a repository. */
|
|
const remotes = new Map<string, string>()
|
|
let cloneResult: { ok: true } | { ok: false; message: string } = { ok: true }
|
|
let siteReachable = true
|
|
let session: AppSessionStatus = { saved: false }
|
|
const git: StartGit = {
|
|
remoteUrl: async (dir) => remotes.get(dir) ?? null,
|
|
clone: async (url, dir) => {
|
|
calls.clones.push([url, dir])
|
|
if (cloneResult.ok) remotes.set(dir, url)
|
|
return cloneResult
|
|
},
|
|
update: async (dir) => {
|
|
calls.updates.push(dir)
|
|
return { ok: true }
|
|
},
|
|
}
|
|
const deps: StartDeps = {
|
|
fetchFn: fetchFn as unknown as typeof fetch,
|
|
fs: mem.fs,
|
|
existsSync: mem.existsSync,
|
|
env: {},
|
|
cwd: () => '/work/my-app',
|
|
hostname: () => 'laptop',
|
|
apiUrl: API,
|
|
appUrl: 'https://app.fallback.example',
|
|
logger: {
|
|
info: (message) => logs.push(stripVTControlCharacters(message)),
|
|
warn: (message) => warnings.push(message),
|
|
},
|
|
scaffoldIsland: async (params) => {
|
|
calls.scaffold.push(params as unknown as Record<string, unknown>)
|
|
mem.files.set(
|
|
`${params.islandDir}/screenci.config.ts`,
|
|
`export default defineConfig({ projectName: '${params.projectName}', projectId: '${params.projectId ?? ''}', envFile: '.env' })`
|
|
)
|
|
},
|
|
installIsland: async ({ islandDir }) => {
|
|
calls.install.push(islandDir)
|
|
},
|
|
installPlaywrightShell: async ({ islandDir }) => {
|
|
calls.shell.push(islandDir)
|
|
},
|
|
installAgentSkills: async (params) => {
|
|
calls.skills.push(params as unknown as Record<string, unknown>)
|
|
},
|
|
findRepoRoot: () => '/work/my-app',
|
|
persistSecret: async (path, secret) => {
|
|
calls.secrets.push([path, secret])
|
|
},
|
|
downloadBrandingVoiceSample: async (params) => {
|
|
calls.sampleDownloads.push(params.islandDir)
|
|
return sampleDownload
|
|
},
|
|
readConfigSource: async (path) => mem.files.get(path) ?? null,
|
|
git,
|
|
probeSite: async (url) => {
|
|
calls.probes.push(url)
|
|
return siteReachable
|
|
},
|
|
now: () => new Date('2026-09-03T12:00:00.000Z'),
|
|
readAppSessionStatus: async ({ configDir, profile }) => {
|
|
calls.sessionReads.push({ configDir, profile })
|
|
return session
|
|
},
|
|
}
|
|
return {
|
|
deps,
|
|
mem,
|
|
logs,
|
|
warnings,
|
|
calls,
|
|
remotes,
|
|
setCloneResult: (next: typeof cloneResult) => {
|
|
cloneResult = next
|
|
},
|
|
setSiteReachable: (next: boolean) => {
|
|
siteReachable = next
|
|
},
|
|
setSession: (next: AppSessionStatus) => {
|
|
session = next
|
|
},
|
|
setSampleDownload: (next: DownloadBrandingSampleResult) => {
|
|
sampleDownload = next
|
|
},
|
|
}
|
|
}
|
|
|
|
const baseOptions = {
|
|
code: CODE,
|
|
force: false,
|
|
packageManager: 'npm' as const,
|
|
verbose: false,
|
|
}
|
|
|
|
describe('exchangeSetupCode', () => {
|
|
it('sends the code, machine, name precedence inputs and package manager', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const result = await exchangeSetupCode(
|
|
{
|
|
apiUrl: API,
|
|
code: ' sc-abcd-efgh ',
|
|
machineName: 'laptop',
|
|
projectName: 'Chosen',
|
|
defaultProjectName: 'my-app',
|
|
packageManager: 'pnpm',
|
|
},
|
|
fetchFn as unknown as typeof fetch
|
|
)
|
|
expect(result.ok).toBe(true)
|
|
const [url, init] = fetchFn.mock.calls[0] as unknown as [
|
|
string,
|
|
RequestInit,
|
|
]
|
|
expect(url).toBe(`${API}/cli/setup/exchange`)
|
|
expect(JSON.parse(init.body as string)).toEqual({
|
|
code: CODE,
|
|
machineName: 'laptop',
|
|
projectName: 'Chosen',
|
|
defaultProjectName: 'my-app',
|
|
packageManager: 'pnpm',
|
|
})
|
|
})
|
|
|
|
it('maps every failure to a typed kind', async () => {
|
|
const attempt = async (response: () => Promise<Response>) =>
|
|
exchangeSetupCode(
|
|
{
|
|
apiUrl: API,
|
|
code: CODE,
|
|
machineName: 'm',
|
|
defaultProjectName: 'd',
|
|
packageManager: 'npm',
|
|
},
|
|
response as unknown as typeof fetch
|
|
)
|
|
expect(
|
|
await attempt(async () =>
|
|
jsonResponse({ error: 'used', code: 'setup_code_used' }, 409)
|
|
)
|
|
).toMatchObject({ ok: false, kind: 'used', message: 'used' })
|
|
expect(
|
|
await attempt(async () =>
|
|
jsonResponse({ error: 'x', code: 'setup_code_expired' }, 410)
|
|
)
|
|
).toMatchObject({ ok: false, kind: 'expired' })
|
|
expect(
|
|
await attempt(async () =>
|
|
jsonResponse({ error: 'x', code: 'setup_code_revoked' }, 410)
|
|
)
|
|
).toMatchObject({ ok: false, kind: 'revoked' })
|
|
expect(
|
|
await attempt(async () =>
|
|
jsonResponse({ error: 'x', code: 'setup_code_invalid' }, 404)
|
|
)
|
|
).toMatchObject({ ok: false, kind: 'invalid' })
|
|
expect(
|
|
await attempt(async () => {
|
|
throw new Error('ECONNREFUSED')
|
|
})
|
|
).toMatchObject({ ok: false, kind: 'unreachable' })
|
|
expect(
|
|
await attempt(async () => jsonResponse({ nope: true }))
|
|
).toMatchObject({
|
|
ok: false,
|
|
kind: 'malformed',
|
|
})
|
|
expect(
|
|
await attempt(async () => new Response('x', { status: 500 }))
|
|
).toMatchObject({ ok: false, kind: 'malformed' })
|
|
})
|
|
|
|
it('refuses a malformed code without a request', async () => {
|
|
const fetchFn = vi.fn()
|
|
const result = await exchangeSetupCode(
|
|
{
|
|
apiUrl: API,
|
|
code: 'not-a-code',
|
|
machineName: 'm',
|
|
defaultProjectName: 'd',
|
|
packageManager: 'npm',
|
|
},
|
|
fetchFn as unknown as typeof fetch
|
|
)
|
|
expect(result).toMatchObject({ ok: false, kind: 'invalid' })
|
|
expect(fetchFn).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('resolveStartWorkspace', () => {
|
|
it('classifies absent, same-project and other-project islands', async () => {
|
|
const absent = memoryFs()
|
|
expect(
|
|
await resolveStartWorkspace('/w/screenci', 'proj_1', {
|
|
existsSync: absent.existsSync,
|
|
readConfigSource: async () => null,
|
|
})
|
|
).toEqual({ state: 'absent' })
|
|
|
|
const same = memoryFs({
|
|
'/w/screenci/screenci.config.ts':
|
|
"export default { projectId: 'proj_1' }",
|
|
})
|
|
expect(
|
|
await resolveStartWorkspace('/w/screenci', 'proj_1', {
|
|
existsSync: same.existsSync,
|
|
readConfigSource: async (path) => same.files.get(path) ?? null,
|
|
})
|
|
).toEqual({ state: 'same-project' })
|
|
|
|
const other = memoryFs({
|
|
'/w/screenci/screenci.config.ts':
|
|
"export default { projectId: 'proj_2' }",
|
|
})
|
|
expect(
|
|
await resolveStartWorkspace('/w/screenci', 'proj_1', {
|
|
existsSync: other.existsSync,
|
|
readConfigSource: async (path) => other.files.get(path) ?? null,
|
|
})
|
|
).toEqual({ state: 'other-project', existingProjectId: 'proj_2' })
|
|
|
|
// A folder without a config (an empty folder, `--dir .`) is usable.
|
|
const bare = memoryFs({ '/w/screenci/notes.txt': 'x' })
|
|
expect(
|
|
await resolveStartWorkspace('/w/screenci', 'proj_1', {
|
|
existsSync: bare.existsSync,
|
|
readConfigSource: async () => null,
|
|
})
|
|
).toEqual({ state: 'absent' })
|
|
|
|
// An island scaffolded by `screenci init` has no projectId at all.
|
|
const repo = memoryFs({
|
|
'/w/screenci/screenci.config.ts': "export default { projectName: 'x' }",
|
|
})
|
|
expect(
|
|
await resolveStartWorkspace('/w/screenci', 'proj_1', {
|
|
existsSync: repo.existsSync,
|
|
readConfigSource: async (path) => repo.files.get(path) ?? null,
|
|
})
|
|
).toEqual({ state: 'unpinned', projectName: 'x' })
|
|
})
|
|
})
|
|
|
|
describe('runSetupCommand', () => {
|
|
it('scaffolds a new project, writes credentials and prints the brief', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, calls, logs } = makeDeps(fetchFn)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('scaffolded')
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
expect(calls.scaffold[0]).toMatchObject({
|
|
islandDir: '/work/my-app/screenci',
|
|
repoRoot: '/work/my-app',
|
|
projectName: 'my-app',
|
|
projectId: 'proj_1',
|
|
packageManager: 'npm',
|
|
installScreenCISkill: true,
|
|
writeGithubWorkflow: false,
|
|
})
|
|
expect(calls.secrets).toEqual([['/work/my-app/screenci/.env', 'secret-1']])
|
|
// The exchange request carried the folder name as the default and no --name.
|
|
const [, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
|
|
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
|
expect(body.defaultProjectName).toBe('my-app')
|
|
expect(body).not.toHaveProperty('projectName')
|
|
// Brief + JSON line.
|
|
expect(logs.some((line) => line.includes('Show the onboarding flow'))).toBe(
|
|
true
|
|
)
|
|
expect(logs.some((line) => line.includes('npx screenci preview'))).toBe(
|
|
true
|
|
)
|
|
const jsonLine = logs.find((line) => line.startsWith('{'))
|
|
expect(jsonLine && JSON.parse(jsonLine)).toMatchObject({
|
|
status: 'ready',
|
|
kind: 'project',
|
|
projectId: 'proj_1',
|
|
outcome: 'scaffolded',
|
|
appUrl: 'https://app.example.com',
|
|
})
|
|
})
|
|
|
|
it('passes --name through as the explicit project name', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ projectName: 'Chosen' }))
|
|
)
|
|
const { deps } = makeDeps(fetchFn)
|
|
await runSetupCommand({ ...baseOptions, name: ' Chosen ' }, deps)
|
|
const [, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
|
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
|
projectName: 'Chosen',
|
|
})
|
|
})
|
|
|
|
it('pulls sources for a video code when no island exists, then installs', async () => {
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange')) {
|
|
return jsonResponse(
|
|
exchangeBody({ kind: 'video', sourcesAvailable: true })
|
|
)
|
|
}
|
|
if (url.includes('/cli/sources/latest')) {
|
|
return jsonResponse(
|
|
{
|
|
files: [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default { projectName: 'my-app', projectId: 'proj_1', envFile: '.env.local' }",
|
|
},
|
|
{ path: 'recordings/a.screenci.ts', content: 'a' },
|
|
],
|
|
},
|
|
200,
|
|
{ 'X-ScreenCI-Source-Bundle-Id': 'sb_1' }
|
|
)
|
|
}
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const { deps, mem, calls } = makeDeps(fetchFn)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('pulled')
|
|
expect(
|
|
mem.files.get('/work/my-app/screenci/recordings/a.screenci.ts')
|
|
).toBe('a')
|
|
expect(calls.scaffold).toHaveLength(0)
|
|
expect(calls.install).toEqual(['/work/my-app/screenci'])
|
|
expect(calls.shell).toEqual(['/work/my-app/screenci'])
|
|
expect(calls.skills[0]).toMatchObject({
|
|
repoRoot: '/work/my-app',
|
|
skills: ['screenci', 'playwright-cli'],
|
|
})
|
|
// The pulled config names its own env file.
|
|
expect(calls.secrets).toEqual([
|
|
['/work/my-app/screenci/.env.local', 'secret-1'],
|
|
])
|
|
// The sources request authenticated with the freshly minted secret.
|
|
const sourcesCall = fetchFn.mock.calls.find(([input]) =>
|
|
String(input).includes('/cli/sources/latest')
|
|
) as unknown as [string, RequestInit]
|
|
expect(sourcesCall[1].headers).toMatchObject({
|
|
'X-ScreenCI-Secret': 'secret-1',
|
|
})
|
|
})
|
|
|
|
it('falls back to scaffolding when a video code has no sources yet', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ kind: 'video', sourcesAvailable: false }))
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.outcome).toBe('scaffolded')
|
|
expect(calls.scaffold).toHaveLength(1)
|
|
})
|
|
|
|
it('uses an existing island of the same project as is, and replaces it only with --force', async () => {
|
|
const island = '/work/my-app/screenci'
|
|
const exchangeResponse = exchangeBody({
|
|
kind: 'edit',
|
|
videoId: 'vid_1',
|
|
videoName: 'Onboarding',
|
|
sourcesAvailable: true,
|
|
})
|
|
const remoteFiles = [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default { projectName: 'my-app', projectId: 'proj_1' }",
|
|
},
|
|
{
|
|
path: 'recordings/onboarding.screenci.ts',
|
|
content: "video('Onboarding', async () => {})",
|
|
},
|
|
]
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange'))
|
|
return jsonResponse(exchangeResponse)
|
|
if (url.includes('/cli/sources/latest'))
|
|
return jsonResponse({ files: remoteFiles })
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const seed = {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app', projectId: 'proj_1' }",
|
|
[`${island}/recordings/onboarding.screenci.ts`]:
|
|
"video('Onboarding', async () => { /* locally edited */ })",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
}
|
|
|
|
// The local workspace wins: nothing is pulled, nothing overwritten.
|
|
const kept = makeDeps(fetchFn, seed)
|
|
const result = await runSetupCommand(baseOptions, kept.deps)
|
|
expect(result.outcome).toBe('existing')
|
|
expect(
|
|
kept.mem.files.get(`${island}/recordings/onboarding.screenci.ts`)
|
|
).toBe("video('Onboarding', async () => { /* locally edited */ })")
|
|
expect(
|
|
fetchFn.mock.calls.some(([url]) =>
|
|
String(url).includes('/cli/sources/latest')
|
|
)
|
|
).toBe(false)
|
|
expect(result.videoSourcePath).toBe(
|
|
'screenci/recordings/onboarding.screenci.ts'
|
|
)
|
|
// node_modules exists, so no install runs.
|
|
expect(kept.calls.install).toHaveLength(0)
|
|
expect(kept.calls.secrets).toEqual([[`${island}/.env`, 'secret-1']])
|
|
expect(kept.logs.join('\n')).toContain('used as is')
|
|
|
|
const forced = makeDeps(fetchFn, seed)
|
|
const replaced = await runSetupCommand(
|
|
{ ...baseOptions, force: true },
|
|
forced.deps
|
|
)
|
|
expect(replaced.outcome).toBe('existing')
|
|
expect(replaced.overwritten).toEqual(['recordings/onboarding.screenci.ts'])
|
|
expect(
|
|
forced.mem.files.get(`${island}/recordings/onboarding.screenci.ts`)
|
|
).toBe("video('Onboarding', async () => {})")
|
|
})
|
|
|
|
it("refuses another project's unpinned island and accepts --dir", async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, calls } = makeDeps(fetchFn, {
|
|
'/work/my-app/screenci/screenci.config.ts':
|
|
"export default { projectName: 'other' }",
|
|
})
|
|
await expect(runSetupCommand(baseOptions, deps)).rejects.toThrow(
|
|
/"other".*--dir/s
|
|
)
|
|
expect(calls.secrets).toHaveLength(0)
|
|
|
|
const alt = makeDeps(fetchFn, {
|
|
'/work/my-app/screenci/screenci.config.ts':
|
|
"export default { projectName: 'other' }",
|
|
})
|
|
const result = await runSetupCommand(
|
|
{ ...baseOptions, dir: 'videos' },
|
|
alt.deps
|
|
)
|
|
expect(result.islandDir).toBe('/work/my-app/videos')
|
|
expect(result.islandDisplayDir).toBe('videos')
|
|
})
|
|
|
|
it('refuses an island pinned to another project after the exchange', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, calls } = makeDeps(fetchFn, {
|
|
'/work/my-app/screenci/screenci.config.ts':
|
|
"export default { projectName: 'other', projectId: 'proj_9' }",
|
|
})
|
|
await expect(runSetupCommand(baseOptions, deps)).rejects.toThrow(
|
|
/proj_9.*--dir/s
|
|
)
|
|
expect(calls.secrets).toHaveLength(0)
|
|
})
|
|
|
|
it('warns when the shell exports a different SCREENCI_SECRET', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, warnings, logs } = makeDeps(fetchFn)
|
|
deps.env = { SCREENCI_SECRET: 'org-wide-secret' }
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.shellSecretOverride).toBe(true)
|
|
expect(warnings.join('\n')).toMatch(/unset SCREENCI_SECRET/)
|
|
expect(logs.join('\n')).toMatch(
|
|
/WARNING: this shell exports a different SCREENCI_SECRET/
|
|
)
|
|
const jsonLine = logs.find((line) => line.startsWith('{'))
|
|
expect(jsonLine && JSON.parse(jsonLine)).toMatchObject({
|
|
shellSecretOverride: true,
|
|
})
|
|
})
|
|
|
|
it("uses the repository's own island when the cwd repository matches the git URL", async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'edit',
|
|
videoName: 'Onboarding',
|
|
videoId: 'vid_1',
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, calls, remotes, logs } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/recordings/onboarding.screenci.ts`]:
|
|
"video('Onboarding', async () => {})",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
})
|
|
remotes.set('/work/my-app', 'git@github.com:acme/app.git')
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('existing')
|
|
expect(result.repo).toEqual({
|
|
state: 'inside',
|
|
dir: '/work/my-app',
|
|
gitUrl: ACME_GIT,
|
|
})
|
|
expect(calls.clones).toHaveLength(0)
|
|
expect(calls.scaffold).toHaveLength(0)
|
|
expect(result.videoSourcePath).toBe(
|
|
'screenci/recordings/onboarding.screenci.ts'
|
|
)
|
|
expect(calls.secrets).toEqual([[`${island}/.env`, 'secret-1']])
|
|
expect(logs.join('\n')).toContain('commit your change on a branch')
|
|
})
|
|
|
|
it('clones the repository outside it as context and keeps the workspace in ./screenci', async () => {
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange')) {
|
|
return jsonResponse(
|
|
exchangeBody({
|
|
kind: 'video',
|
|
sourcesAvailable: true,
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
}
|
|
if (url.includes('/cli/sources/latest')) {
|
|
return jsonResponse({
|
|
files: [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default { projectName: 'my-app', projectId: 'proj_1' }",
|
|
},
|
|
],
|
|
})
|
|
}
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const clone = '/work/my-app/.screenci/repo'
|
|
const { deps, calls, mem, logs } = makeDeps(fetchFn)
|
|
// The clone "appears" with an island once git clone ran.
|
|
deps.git.clone = async (url, dir) => {
|
|
calls.clones.push([url, dir])
|
|
mem.files.set(
|
|
`${dir}/screenci/screenci.config.ts`,
|
|
"export default { projectName: 'my-app', envFile: '.env' }"
|
|
)
|
|
return { ok: true }
|
|
}
|
|
deps.git.remoteUrl = async (dir) => (dir === clone ? ACME_GIT : null)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(calls.clones).toEqual([[ACME_GIT, clone]])
|
|
expect(mem.files.get('/work/my-app/.screenci/.gitignore')).toBe('*\n')
|
|
expect(result.repo).toMatchObject({
|
|
state: 'cloned',
|
|
dir: clone,
|
|
fresh: true,
|
|
})
|
|
// The clone is read-only context: the workspace never lives in it, so
|
|
// the snapshot lands where the agent can commit.
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
expect(result.outcome).toBe('pulled')
|
|
expect(calls.install).toEqual(['/work/my-app/screenci'])
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('Do not edit or commit there')
|
|
expect(brief).not.toContain('Add to repository')
|
|
})
|
|
|
|
it('treats a repository holding the project island as inside even without a matching remote', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'edit',
|
|
videoName: 'Onboarding',
|
|
videoId: 'vid_1',
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, calls, remotes } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
})
|
|
// A fork: the remote differs from the configured URL.
|
|
remotes.set('/work/my-app', 'git@github.com:fork/app.git')
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.repo).toEqual({
|
|
state: 'inside',
|
|
dir: '/work/my-app',
|
|
gitUrl: 'git@github.com:fork/app.git',
|
|
})
|
|
expect(result.outcome).toBe('existing')
|
|
expect(calls.clones).toHaveLength(0)
|
|
})
|
|
|
|
it('tells a record code to trigger the pipeline when CI records the project', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'record',
|
|
videoName: 'Sign up (beta)',
|
|
videoId: 'vid_1',
|
|
ciRecords: true,
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, remotes, logs } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/recordings/signup.screenci.ts`]:
|
|
"video('Sign up (beta)', async () => {})",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.videoSourcePath).toBe(
|
|
'screenci/recordings/signup.screenci.ts'
|
|
)
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('Re-record the ScreenCI video "Sign up (beta)"')
|
|
expect(brief).toContain(
|
|
'Record screenci/recordings/signup.screenci.ts again as it is'
|
|
)
|
|
expect(brief).toContain('A CI pipeline already records this project')
|
|
// The title is a regex for Playwright: escaped and anchored.
|
|
expect(brief).toContain(
|
|
"gh workflow run screenci.yaml -f grep='^Sign up \\(beta\\)$'"
|
|
)
|
|
expect(brief).toContain('npx screenci preview "Sign up (beta)"')
|
|
expect(brief).toContain('"ciRecords":true')
|
|
})
|
|
|
|
it('tells a whole-project record code to preview without a title', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'record',
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, remotes, logs } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
await runSetupCommand(baseOptions, deps)
|
|
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain(
|
|
'Re-record every video of the ScreenCI project "my-app"'
|
|
)
|
|
expect(brief).toContain(
|
|
'npx screenci preview # re-record every video'
|
|
)
|
|
expect(brief).not.toContain('A CI pipeline already records this project')
|
|
})
|
|
|
|
it('finds the script for a language code and tells the agent to translate', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'language',
|
|
videoName: 'Onboarding',
|
|
videoId: 'vid_1',
|
|
task: { description: 'Add fi', language: 'fi' },
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, remotes, logs } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/recordings/onboarding.screenci.ts`]:
|
|
"video('Onboarding', async () => {})",
|
|
[`${island}/node_modules/.keep`]: '',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.videoSourcePath).toBe(
|
|
'screenci/recordings/onboarding.screenci.ts'
|
|
)
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('Add the language "fi"')
|
|
expect(brief).toContain('video.languages([...])')
|
|
expect(brief).toContain('npx screenci preview "Onboarding"')
|
|
expect(brief).toContain('"language":"fi"')
|
|
})
|
|
|
|
it('keeps ./screenci for a new project even when the clone has no island', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({ aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT } })
|
|
)
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(calls.clones).toHaveLength(1)
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
expect(result.outcome).toBe('scaffolded')
|
|
})
|
|
|
|
it('reports a failed clone and continues when the site answers', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
task: { description: 'x', appUrl: 'https://staging.acme.com' },
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const { deps, warnings, logs, setCloneResult } = makeDeps(fetchFn)
|
|
setCloneResult({ ok: false, message: 'Permission denied (publickey)' })
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.repo).toMatchObject({ state: 'clone-failed' })
|
|
expect(result.stop).toBeNull()
|
|
expect(warnings.join('\n')).toContain('Permission denied')
|
|
expect(logs.join('\n')).toContain('could not be cloned')
|
|
})
|
|
|
|
it('stops for a local site that is down when starting it is not allowed', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
aiContext: {
|
|
...EMPTY_AI_CONTEXT,
|
|
siteUrl: 'http://localhost:3000',
|
|
gitUrl: ACME_GIT,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
const { deps, calls, logs, setSiteReachable } = makeDeps(fetchFn)
|
|
setSiteReachable(false)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(calls.probes).toEqual(['http://localhost:3000'])
|
|
expect(result.site).toEqual({
|
|
state: 'checked',
|
|
url: 'http://localhost:3000',
|
|
kind: 'local',
|
|
reachable: false,
|
|
})
|
|
expect(result.stop).toMatchObject({
|
|
reason: 'site-unreachable-local',
|
|
docsUrl: 'https://example.com/docs/guides/ai-context',
|
|
})
|
|
// The workspace was still prepared.
|
|
expect(calls.secrets).toHaveLength(1)
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('## STOP')
|
|
expect(brief).toContain('switched off')
|
|
const jsonLine = logs.find((line) => line.startsWith('{'))
|
|
expect(jsonLine && JSON.parse(jsonLine)).toMatchObject({
|
|
status: 'stopped',
|
|
stop: { reason: 'site-unreachable-local' },
|
|
})
|
|
})
|
|
|
|
it('lets the agent start a local site from the repository when allowed', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
aiContext: {
|
|
...EMPTY_AI_CONTEXT,
|
|
siteUrl: 'http://localhost:3000',
|
|
gitUrl: ACME_GIT,
|
|
runLocallyIfNeeded: true,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
const { deps, logs, setSiteReachable } = makeDeps(fetchFn)
|
|
setSiteReachable(false)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.stop).toBeNull()
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('start it from the repository')
|
|
expect(brief).toContain('SCREENCI_APP_LAUNCHED_BY=agent')
|
|
})
|
|
|
|
it('stops for a deployed site that is down unless the check is skipped', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
aiContext: { ...EMPTY_AI_CONTEXT, siteUrl: 'https://app.acme.com' },
|
|
})
|
|
)
|
|
)
|
|
const down = makeDeps(fetchFn)
|
|
down.setSiteReachable(false)
|
|
const stopped = await runSetupCommand(baseOptions, down.deps)
|
|
expect(stopped.stop).toMatchObject({ reason: 'site-unreachable' })
|
|
|
|
const skipped = makeDeps(fetchFn)
|
|
skipped.setSiteReachable(false)
|
|
const result = await runSetupCommand(
|
|
{ ...baseOptions, skipSiteCheck: true },
|
|
skipped.deps
|
|
)
|
|
expect(result.site).toEqual({
|
|
state: 'unchecked',
|
|
url: 'https://app.acme.com',
|
|
kind: 'deployed',
|
|
})
|
|
expect(result.stop).toBeNull()
|
|
expect(skipped.calls.probes).toHaveLength(0)
|
|
})
|
|
|
|
it('prefers the task app URL over the context site URL', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
task: { description: 'x', appUrl: 'https://staging.acme.com' },
|
|
aiContext: { ...EMPTY_AI_CONTEXT, siteUrl: 'https://app.acme.com' },
|
|
})
|
|
)
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn)
|
|
await runSetupCommand(baseOptions, deps)
|
|
expect(calls.probes).toEqual(['https://staging.acme.com'])
|
|
})
|
|
|
|
it('tells the agent to open a sign-in browser when no session is saved', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, calls, logs } = makeDeps(fetchFn)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
// Read from the island on disk, never from the service.
|
|
expect(calls.sessionReads).toEqual([
|
|
{ configDir: '/work/my-app/screenci', profile: 'default' },
|
|
])
|
|
expect(result.session).toEqual({ saved: false })
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('## Signing in')
|
|
expect(brief).toContain('npx screenci login')
|
|
expect(brief).toContain('npx screenci login --done')
|
|
expect(brief).toContain('never ask them for a password or a code yourself')
|
|
// The credential path is gone for good.
|
|
expect(brief).not.toContain('APP_USERNAME')
|
|
expect(brief).not.toContain('APP_PASSWORD')
|
|
expect(brief).not.toContain('pull-login')
|
|
})
|
|
|
|
it('hands over the session file path and forbids a hand-rolled explore script', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, logs } = makeDeps(fetchFn)
|
|
await runSetupCommand(baseOptions, deps)
|
|
const brief = logs.join('\n')
|
|
|
|
// Exploring with its own Playwright script is what sent an agent chasing
|
|
// selectors on a signed-out page for several minutes.
|
|
expect(brief).toContain('never a Playwright script of your own')
|
|
expect(brief).toContain(
|
|
'playwright-cli state-load screenci/.screenci/auth/default.json'
|
|
)
|
|
})
|
|
|
|
it('tells the agent not to script a sign-in when a session is already saved', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const withSession = makeDeps(fetchFn)
|
|
withSession.setSession({
|
|
saved: true,
|
|
path: '/work/my-app/screenci/.screenci/auth/default.json',
|
|
meta: {
|
|
profile: 'default',
|
|
origin: 'https://app.example.com',
|
|
savedAt: '2026-09-03T11:00:00.000Z',
|
|
expiresAt: null,
|
|
},
|
|
expired: false,
|
|
})
|
|
await runSetupCommand(baseOptions, withSession.deps)
|
|
const brief = withSession.logs.join('\n')
|
|
expect(brief).toContain('already saved on this machine')
|
|
expect(brief).toContain('WITHOUT any sign-in steps')
|
|
// The exact file, so `state-load` can be copied rather than guessed at.
|
|
expect(brief).toContain('screenci/.screenci/auth/default.json')
|
|
expect(brief).toContain('playwright-cli state-load')
|
|
})
|
|
|
|
it('says the session expired and how to renew it', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const expired = makeDeps(fetchFn)
|
|
expired.setSession({
|
|
saved: true,
|
|
path: '/work/my-app/screenci/.screenci/auth/default.json',
|
|
meta: {
|
|
profile: 'default',
|
|
origin: 'https://app.example.com',
|
|
savedAt: '2026-08-01T12:00:00.000Z',
|
|
expiresAt: '2026-08-30T12:00:00.000Z',
|
|
},
|
|
expired: true,
|
|
})
|
|
await runSetupCommand(baseOptions, expired.deps)
|
|
expect(expired.logs.join('\n')).toContain('The saved session expired.')
|
|
})
|
|
|
|
it('states the sign-in as a fact when the team said the site needs one', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
aiContext: { ...EMPTY_AI_CONTEXT, siteRequiresLogin: true },
|
|
})
|
|
)
|
|
)
|
|
const { deps, logs } = makeDeps(fetchFn)
|
|
await runSetupCommand(baseOptions, deps)
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('The team says this site needs a sign-in.')
|
|
expect(brief).not.toContain('If the flow you are asked to record sits')
|
|
})
|
|
|
|
it('leaves the sign-in conditional when the team said nothing', async () => {
|
|
const fetchFn = vi.fn(async () => jsonResponse(exchangeBody()))
|
|
const { deps, logs } = makeDeps(fetchFn)
|
|
await runSetupCommand(baseOptions, deps)
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('If the flow you are asked to record sits behind')
|
|
expect(brief).not.toContain('The team says this site needs a sign-in.')
|
|
})
|
|
|
|
it('installs skills in the cwd repository, not in the clone, and keeps a new project out of a foreign repo island', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({ aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT } })
|
|
)
|
|
)
|
|
const clone = '/work/my-app/.screenci/repo'
|
|
const { deps, calls, mem } = makeDeps(fetchFn)
|
|
deps.git.clone = async (url, dir) => {
|
|
calls.clones.push([url, dir])
|
|
mem.files.set(
|
|
`${dir}/screenci/screenci.config.ts`,
|
|
"export default { projectName: 'other-product' }"
|
|
)
|
|
return { ok: true }
|
|
}
|
|
deps.git.remoteUrl = async (dir) => (dir === clone ? ACME_GIT : null)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
// A new project never adopts the repository's island.
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
expect(result.outcome).toBe('scaffolded')
|
|
expect(calls.scaffold[0]).toMatchObject({ repoRoot: '/work/my-app' })
|
|
})
|
|
|
|
it('refuses CI without a repository', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ kind: 'ci', sourcesAvailable: true }))
|
|
)
|
|
const { deps } = makeDeps(fetchFn)
|
|
await expect(runSetupCommand(baseOptions, deps)).rejects.toThrow(
|
|
/No repository URL is known/
|
|
)
|
|
})
|
|
|
|
it('uses the repository CI setup runs in when no URL is configured', async () => {
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange')) {
|
|
return jsonResponse(
|
|
exchangeBody({ kind: 'ci', sourcesAvailable: true })
|
|
)
|
|
}
|
|
if (url.includes('/cli/sources/latest')) {
|
|
return jsonResponse({
|
|
files: [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default defineConfig({ projectName: 'my-app', projectId: 'proj_1' })\n",
|
|
},
|
|
],
|
|
})
|
|
}
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const { deps, remotes, calls } = makeDeps(fetchFn)
|
|
// The prompt says "run this inside the repository"; nobody told ScreenCI
|
|
// the URL, so the cwd's remote is the answer.
|
|
remotes.set('/work/my-app', 'git@github.com:acme/app.git')
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.repo).toEqual({
|
|
state: 'inside',
|
|
dir: '/work/my-app',
|
|
gitUrl: 'git@github.com:acme/app.git',
|
|
})
|
|
expect(result.outcome).toBe('pulled')
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
expect(calls.clones).toHaveLength(0)
|
|
})
|
|
|
|
it('prepares CI for a repository with a workspace: detects providers, skips the site check, prints the CI brief', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'ci',
|
|
task: { description: 'Set up CI recording for this project.' },
|
|
aiContext: {
|
|
...EMPTY_AI_CONTEXT,
|
|
gitUrl: ACME_GIT,
|
|
siteUrl: 'https://staging.acme.com',
|
|
},
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, calls, remotes, logs } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
'/work/my-app/.gitlab-ci.yml': 'stages: []',
|
|
'/work/my-app/.github/workflows/deploy.yaml': 'name: deploy',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('existing')
|
|
expect(result.ci).toEqual({
|
|
providers: ['github', 'gitlab'],
|
|
githubWorkflowExists: false,
|
|
})
|
|
// CI records elsewhere: the site is never probed and nothing stops.
|
|
expect(calls.probes).toHaveLength(0)
|
|
expect(result.stop).toBeNull()
|
|
expect(calls.install).toEqual([island])
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('Record the ScreenCI project "my-app" from CI.')
|
|
expect(brief).toContain('GitHub Actions (.github/workflows/)')
|
|
expect(brief).toContain('GitLab CI (.gitlab-ci.yml)')
|
|
expect(brief).toContain(
|
|
`gh secret set SCREENCI_SECRET --body "$(grep '^SCREENCI_SECRET=' screenci/.env | cut -d= -f2-)"`
|
|
)
|
|
expect(brief).toContain('glab variable set SCREENCI_SECRET --masked')
|
|
expect(brief).toContain('npx screenci ci-workflow')
|
|
expect(brief).toContain('/docs/ci-setup#other-providers')
|
|
expect(brief).toContain(
|
|
'only a run made by the pipeline completes this setup'
|
|
)
|
|
// The key itself never appears in the brief.
|
|
expect(brief).not.toContain('secret-1')
|
|
expect(brief).not.toContain('## Site')
|
|
expect(brief).not.toContain('## Signing in')
|
|
expect(brief).not.toContain('Commit the sources first')
|
|
})
|
|
|
|
it('pulls the snapshot into the repository on the way to CI when it has no workspace', async () => {
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange')) {
|
|
return jsonResponse(
|
|
exchangeBody({
|
|
kind: 'ci',
|
|
sourcesAvailable: true,
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
}
|
|
if (url.includes('/cli/sources/latest')) {
|
|
return jsonResponse({
|
|
files: [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default defineConfig({\n projectName: 'my-app',\n projectId: 'proj_1',\n envFile: '.env',\n})\n",
|
|
},
|
|
],
|
|
})
|
|
}
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const { deps, mem, remotes, logs } = makeDeps(fetchFn, {
|
|
'/work/my-app/.github/workflows/screenci.yaml': 'name: ScreenCI',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('pulled')
|
|
expect(result.islandDir).toBe('/work/my-app/screenci')
|
|
// The snapshot is committed as is; projectId stays (it is identity only).
|
|
expect(mem.files.get('/work/my-app/screenci/screenci.config.ts')).toContain(
|
|
"projectId: 'proj_1'"
|
|
)
|
|
expect(result.ci).toEqual({
|
|
providers: ['github'],
|
|
githubWorkflowExists: true,
|
|
})
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('## 1. Commit the sources first')
|
|
expect(brief).not.toContain('merge-complete')
|
|
expect(brief).toContain('screenci.yaml already exists')
|
|
expect(brief).not.toContain('npx screenci ci-workflow')
|
|
})
|
|
|
|
it('leaves an existing repository workspace alone for CI even when ScreenCI holds a snapshot', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'ci',
|
|
sourcesAvailable: true,
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, mem, remotes } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default { projectName: 'my-app' }",
|
|
[`${island}/recordings/a.screenci.ts`]: 'edited in git',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
|
|
expect(result.outcome).toBe('existing')
|
|
expect(mem.files.get(`${island}/recordings/a.screenci.ts`)).toBe(
|
|
'edited in git'
|
|
)
|
|
expect(
|
|
fetchFn.mock.calls.some(([url]) =>
|
|
String(url).includes('/cli/sources/latest')
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('refuses CI for a repository project whose sources are nowhere', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'ci',
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const { deps, remotes } = makeDeps(fetchFn)
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
await expect(runSetupCommand(baseOptions, deps)).rejects.toThrow(
|
|
/Record a video first/
|
|
)
|
|
})
|
|
|
|
it('replaces an unpinned repository workspace with the snapshot only with --force', async () => {
|
|
const fetchFn = vi.fn(async (input: string | URL) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/cli/setup/exchange')) {
|
|
return jsonResponse(
|
|
exchangeBody({
|
|
kind: 'video',
|
|
sourcesAvailable: true,
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
}
|
|
if (url.includes('/cli/sources/latest')) {
|
|
return jsonResponse({
|
|
files: [
|
|
{
|
|
path: 'screenci.config.ts',
|
|
content:
|
|
"export default defineConfig({ projectName: 'my-app' })\n",
|
|
},
|
|
{ path: 'recordings/a.screenci.ts', content: 'edited' },
|
|
],
|
|
})
|
|
}
|
|
return jsonResponse({}, 404)
|
|
})
|
|
const island = '/work/my-app/screenci'
|
|
const { deps, mem, remotes } = makeDeps(fetchFn, {
|
|
[`${island}/screenci.config.ts`]:
|
|
"export default defineConfig({ projectName: 'my-app' })\n",
|
|
[`${island}/recordings/a.screenci.ts`]: 'original',
|
|
})
|
|
remotes.set('/work/my-app', ACME_GIT)
|
|
|
|
const result = await runSetupCommand({ ...baseOptions, force: true }, deps)
|
|
|
|
expect(result.outcome).toBe('existing')
|
|
expect(mem.files.get(`${island}/recordings/a.screenci.ts`)).toBe('edited')
|
|
expect(result.overwritten).toEqual(['recordings/a.screenci.ts'])
|
|
})
|
|
|
|
it('refuses an edit code when the script is nowhere on this machine and ScreenCI holds no snapshot', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
kind: 'edit',
|
|
videoId: 'vid_1',
|
|
videoName: 'Onboarding',
|
|
aiContext: { ...EMPTY_AI_CONTEXT, gitUrl: ACME_GIT },
|
|
})
|
|
)
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn)
|
|
await expect(runSetupCommand(baseOptions, deps)).rejects.toThrow(
|
|
/"Onboarding".*inside the repository/s
|
|
)
|
|
expect(calls.scaffold).toHaveLength(0)
|
|
expect(calls.secrets).toHaveLength(0)
|
|
})
|
|
|
|
it('scaffolds a workspace for a video code when ScreenCI holds no snapshot', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ kind: 'video' }))
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.outcome).toBe('scaffolded')
|
|
expect(calls.scaffold).toHaveLength(1)
|
|
})
|
|
|
|
it('surfaces exchange failures as StartError with the failure kind', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
{ error: 'That setup code was already used', code: 'setup_code_used' },
|
|
409
|
|
)
|
|
)
|
|
const { deps } = makeDeps(fetchFn)
|
|
const error = await runSetupCommand(baseOptions, deps).catch((err) => err)
|
|
expect(error).toBeInstanceOf(StartError)
|
|
expect((error as StartError).failure).toBe('used')
|
|
expect((error as StartError).message).toBe(
|
|
'That setup code was already used'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('siteRootOf', () => {
|
|
it('maps app hosts to the docs site and falls back to production', () => {
|
|
expect(siteRootOf('https://app.screenci.com')).toBe('https://screenci.com')
|
|
expect(siteRootOf('https://dev.app.screenci.com/')).toBe(
|
|
'https://dev.screenci.com'
|
|
)
|
|
expect(siteRootOf('http://localhost:5173')).toBe('https://screenci.com')
|
|
})
|
|
})
|
|
|
|
describe('findVideoSourceFile', () => {
|
|
it('finds the script that declares the title', async () => {
|
|
const { fs } = memoryFs({
|
|
'/i/recordings/a.screenci.ts': "video('Other', async () => {})",
|
|
'/i/recordings/nested/b.screenci.tsx':
|
|
'video("Onboarding", async () => {})',
|
|
'/i/recordings/assets/logo.png': 'binary',
|
|
})
|
|
expect(await findVideoSourceFile('/i', 'Onboarding', fs)).toBe(
|
|
'/i/recordings/nested/b.screenci.tsx'
|
|
)
|
|
expect(await findVideoSourceFile('/i', 'Missing', fs)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('formatStartBrief', () => {
|
|
function result(overrides: Partial<StartResult> = {}): StartResult {
|
|
return {
|
|
exchange: exchange(),
|
|
brandingSample: { status: 'none' },
|
|
brandingAssetPaths: {},
|
|
shellSecretOverride: false,
|
|
islandDir: '/work/my-app/screenci',
|
|
islandDisplayDir: 'screenci',
|
|
packageManager: 'npm',
|
|
outcome: 'scaffolded',
|
|
envFilePath: '/work/my-app/screenci/.env',
|
|
overwritten: [],
|
|
videoSourcePath: null,
|
|
appUrl: 'https://app.example.com',
|
|
repo: { state: 'not-configured' },
|
|
site: { state: 'none' },
|
|
session: { saved: false },
|
|
stop: null,
|
|
ci: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
it('describes a new project with the naming note and the app URL guidance', () => {
|
|
const brief = stripVTControlCharacters(
|
|
formatStartBrief(
|
|
result({
|
|
exchange: exchange({
|
|
task: {
|
|
description: 'Show billing',
|
|
appUrl: 'https://staging.acme.com',
|
|
},
|
|
}),
|
|
site: {
|
|
state: 'checked',
|
|
url: 'https://staging.acme.com',
|
|
kind: 'deployed',
|
|
reachable: true,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
expect(brief).toContain(
|
|
'Create a video for the new ScreenCI project "my-app".'
|
|
)
|
|
expect(brief).toContain('Show billing')
|
|
expect(brief).toContain('https://staging.acme.com')
|
|
expect(brief).toContain('cd screenci')
|
|
expect(brief).toContain('npx screenci test')
|
|
expect(brief).toContain('npx screenci preview "<video title>"')
|
|
expect(brief).toContain('from --name or the folder')
|
|
expect(brief).not.toContain('—')
|
|
})
|
|
|
|
it('points an edit code at the located script and its preview command', () => {
|
|
const brief = formatStartBrief(
|
|
result({
|
|
exchange: exchange({
|
|
kind: 'edit',
|
|
videoName: 'Onboarding',
|
|
videoId: 'vid_1',
|
|
}),
|
|
videoSourcePath: 'screenci/recordings/onboarding.screenci.ts',
|
|
packageManager: 'pnpm',
|
|
outcome: 'existing',
|
|
})
|
|
)
|
|
expect(brief).toContain('Edit screenci/recordings/onboarding.screenci.ts')
|
|
expect(brief).toContain('pnpm exec screenci preview "Onboarding"')
|
|
expect(brief).toContain('existing workspace, used as is')
|
|
})
|
|
|
|
it('tells a video code to add a new script and warns when no app URL exists', () => {
|
|
const brief = formatStartBrief(
|
|
result({ exchange: exchange({ kind: 'video' }), outcome: 'pulled' })
|
|
)
|
|
expect(brief).toContain(
|
|
'Add a new script screenci/recordings/<flow>.screenci.ts'
|
|
)
|
|
expect(brief).toContain('No site URL was given')
|
|
})
|
|
|
|
it('includes the team notes and the repository context', () => {
|
|
const brief = formatStartBrief(
|
|
result({
|
|
exchange: exchange({
|
|
aiContext: { ...EMPTY_AI_CONTEXT, guide: 'Use the demo tenant.' },
|
|
}),
|
|
repo: {
|
|
state: 'cloned',
|
|
dir: '/work/.screenci/repo',
|
|
gitUrl: ACME_GIT,
|
|
fresh: true,
|
|
},
|
|
}),
|
|
'/work'
|
|
)
|
|
expect(brief).toContain('## Notes from the team')
|
|
expect(brief).toContain('Use the demo tenant.')
|
|
expect(brief).toContain('cloned at .screenci/repo/')
|
|
expect(brief).toContain('/docs/guides/ai-context')
|
|
})
|
|
|
|
it('emits a machine-readable line', () => {
|
|
expect(
|
|
formatStartJsonLine(
|
|
result({
|
|
exchange: exchange({
|
|
kind: 'edit',
|
|
videoName: 'Onboarding',
|
|
videoId: 'vid_1',
|
|
}),
|
|
videoSourcePath: 'screenci/recordings/onboarding.screenci.ts',
|
|
})
|
|
)
|
|
).toEqual({
|
|
status: 'ready',
|
|
kind: 'edit',
|
|
projectId: 'proj_1',
|
|
projectName: 'my-app',
|
|
videoId: 'vid_1',
|
|
videoName: 'Onboarding',
|
|
videoSourcePath: 'screenci/recordings/onboarding.screenci.ts',
|
|
workspace: '/work/my-app/screenci',
|
|
outcome: 'scaffolded',
|
|
appUrl: 'https://app.example.com',
|
|
description: 'Show the onboarding flow',
|
|
repo: { state: 'not-configured' },
|
|
site: { state: 'none' },
|
|
session: { saved: false, expired: false },
|
|
siteRequiresLogin: false,
|
|
runLocallyIfNeeded: false,
|
|
branding: EMPTY_BRANDING,
|
|
ciRecords: false,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('registerSetupCommand', () => {
|
|
it('parses the setup options and forwards them', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ projectName: 'X' }))
|
|
)
|
|
const { deps, calls } = makeDeps(fetchFn, {
|
|
'/work/my-app/tmp/screenci.config.ts':
|
|
"export default { projectId: 'proj_1' }",
|
|
})
|
|
const program = new Command()
|
|
program.exitOverride()
|
|
registerSetupCommand(program, deps, 'npm')
|
|
await program.parseAsync(
|
|
[
|
|
'setup',
|
|
CODE,
|
|
'--name',
|
|
'X',
|
|
'--dir',
|
|
'tmp',
|
|
'--force',
|
|
'--package-manager',
|
|
'yarn',
|
|
'--skip-site-check',
|
|
'--no-clone',
|
|
],
|
|
{ from: 'user' }
|
|
)
|
|
const [, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
|
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
|
projectName: 'X',
|
|
packageManager: 'yarn',
|
|
})
|
|
// Same project: the existing workspace is used without a scaffold.
|
|
expect(calls.scaffold).toHaveLength(0)
|
|
expect(calls.secrets[0]?.[0]).toBe('/work/my-app/tmp/.env')
|
|
expect(calls.probes).toHaveLength(0)
|
|
expect(calls.clones).toHaveLength(0)
|
|
})
|
|
|
|
it('exits with code 2 when the agent must stop', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
aiContext: { ...EMPTY_AI_CONTEXT, siteUrl: 'http://localhost:3000' },
|
|
})
|
|
)
|
|
)
|
|
const { deps, setSiteReachable } = makeDeps(fetchFn)
|
|
setSiteReachable(false)
|
|
const program = new Command()
|
|
program.exitOverride()
|
|
registerSetupCommand(program, deps, 'npm')
|
|
const previous = process.exitCode
|
|
try {
|
|
await program.parseAsync(['setup', CODE], { from: 'user' })
|
|
expect(process.exitCode).toBe(2)
|
|
} finally {
|
|
process.exitCode = previous
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('branding', () => {
|
|
const HASH = 'a'.repeat(64)
|
|
const branded = {
|
|
...EMPTY_BRANDING,
|
|
backgroundCss: '#334155',
|
|
aspectRatio: '9:16' as const,
|
|
cursorStyle: 'black' as const,
|
|
voice: { kind: 'builtIn' as const, name: 'Ava' },
|
|
sources: {
|
|
...EMPTY_BRANDING.sources,
|
|
backgroundCss: 'org' as const,
|
|
aspectRatio: 'project' as const,
|
|
cursorStyle: 'org' as const,
|
|
voice: 'org' as const,
|
|
},
|
|
}
|
|
|
|
it('falls back to the empty branding for an older server', async () => {
|
|
const body = exchangeBody() as Record<string, unknown>
|
|
delete body.branding
|
|
const fetchFn = vi.fn(async () => jsonResponse(body))
|
|
const { deps, logs } = makeDeps(fetchFn)
|
|
const result = await runSetupCommand(baseOptions, deps)
|
|
expect(result.exchange.branding).toEqual(EMPTY_BRANDING)
|
|
expect(result.brandingSample).toEqual({ status: 'none' })
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('## Branding')
|
|
expect(brief).toContain('No organisation branding is set')
|
|
})
|
|
|
|
it('prints the branding with its code snippet and the JSON line', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(exchangeBody({ branding: branded }))
|
|
)
|
|
const { deps, logs, calls } = makeDeps(fetchFn)
|
|
await runSetupCommand(baseOptions, deps)
|
|
expect(calls.sampleDownloads).toEqual([])
|
|
const brief = logs.join('\n')
|
|
expect(brief).toContain('## Branding')
|
|
expect(brief).toContain('- Aspect ratio: 9:16 (project override)')
|
|
expect(brief).toContain('- Narration voice: Ava (built-in)')
|
|
expect(brief).toContain('mouse: { style: "black" }')
|
|
expect(brief).toContain('.recordOptions({ aspectRatio: "9:16" })')
|
|
expect(brief).toContain('/docs/guides/branding')
|
|
const jsonLine = logs.find((line) => line.startsWith('{'))
|
|
expect(jsonLine && JSON.parse(jsonLine)).toMatchObject({
|
|
branding: {
|
|
cursorStyle: 'black',
|
|
voice: { kind: 'builtIn', name: 'Ava' },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('downloads the voice sample into the island and points the snippet at it', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
branding: {
|
|
...EMPTY_BRANDING,
|
|
voice: {
|
|
kind: 'sample',
|
|
fileHash: HASH,
|
|
fileName: 'brand voice.mp3',
|
|
},
|
|
sources: { ...EMPTY_BRANDING.sources, voice: 'org' },
|
|
},
|
|
})
|
|
)
|
|
)
|
|
const harness = makeDeps(fetchFn)
|
|
harness.setSampleDownload({
|
|
status: 'written',
|
|
relativePath: 'branding/brand-voice.mp3',
|
|
fileName: 'brand-voice.mp3',
|
|
})
|
|
const result = await runSetupCommand(baseOptions, harness.deps)
|
|
expect(harness.calls.sampleDownloads).toEqual(['/work/my-app/screenci'])
|
|
expect(result.brandingSample).toEqual({
|
|
status: 'downloaded',
|
|
relativePath: 'branding/brand-voice.mp3',
|
|
})
|
|
const brief = harness.logs.join('\n')
|
|
expect(brief).toContain(
|
|
'voices.elevenlabs({ path: "./branding/brand-voice.mp3" })'
|
|
)
|
|
expect(brief).toContain("import { video, voices } from 'screenci'")
|
|
const jsonLine = harness.logs.find((line) => line.startsWith('{'))
|
|
expect(jsonLine && JSON.parse(jsonLine)).toMatchObject({
|
|
brandingSamplePath: 'branding/brand-voice.mp3',
|
|
})
|
|
})
|
|
|
|
it('warns when the sample download fails and says how to retry', async () => {
|
|
const fetchFn = vi.fn(async () =>
|
|
jsonResponse(
|
|
exchangeBody({
|
|
branding: {
|
|
...EMPTY_BRANDING,
|
|
voice: { kind: 'sample', fileHash: HASH, fileName: 'v.mp3' },
|
|
sources: { ...EMPTY_BRANDING.sources, voice: 'org' },
|
|
},
|
|
})
|
|
)
|
|
)
|
|
const harness = makeDeps(fetchFn)
|
|
harness.setSampleDownload({ status: 'error', message: 'boom' })
|
|
const result = await runSetupCommand(baseOptions, harness.deps)
|
|
expect(result.brandingSample).toEqual({ status: 'failed', message: 'boom' })
|
|
expect(harness.warnings.join('\n')).toContain('boom')
|
|
const brief = harness.logs.join('\n')
|
|
expect(brief).toContain('npx screenci context')
|
|
expect(brief).not.toContain('voices.elevenlabs')
|
|
})
|
|
})
|