diff --git a/src/clients/caprover.ts b/src/clients/caprover.ts index 826d0f3..80e8a0d 100644 --- a/src/clients/caprover.ts +++ b/src/clients/caprover.ts @@ -38,6 +38,23 @@ type NginxConfig = Awaited> type UnusedImage = Awaited< ReturnType >['unusedImages'][number] +type OneClickAppsResponse = Awaited< + ReturnType +> +type OneClickAppDefinitionResponse = Awaited< + ReturnType +> +type OneClickAppRepositories = Awaited< + ReturnType +> +type OneClickDeploymentState = Awaited< + ReturnType +> + +export interface OneClickValuePair { + key: string + value: string +} const API_TIMEOUT_MS = 30_000 const DEPLOYMENT_TIMEOUT_MS = 90_000 @@ -385,6 +402,65 @@ export class CapRoverClient { ) } + getAllOneClickApps(): Promise { + return this.request( + () => this.api.getAllOneClickApps(), + 'listing one-click application templates' + ) + } + + getOneClickAppByName( + appName: string, + baseDomain: string + ): Promise { + return this.request( + () => this.api.getOneClickAppByName(appName, baseDomain), + `retrieving one-click application template ${appName}` + ) + } + + getAllOneClickAppRepos(): Promise { + return this.request( + () => this.api.getAllOneClickAppRepos(), + 'listing custom one-click repositories' + ) + } + + addCustomOneClickRepo(repositoryUrl: string): Promise { + return this.request( + () => this.api.addNewCustomOneClickRepo(repositoryUrl), + `adding custom one-click repository ${repositoryUrl}` + ) + } + + deleteCustomOneClickRepo(repositoryUrl: string): Promise { + return this.request( + () => this.api.deleteCustomOneClickRepo(repositoryUrl), + `deleting custom one-click repository ${repositoryUrl}` + ) + } + + startOneClickAppDeploy( + template: CapRoverModels.IOneClickTemplate, + values: OneClickValuePair[], + templateName?: string + ): Promise<{ jobId: string }> { + return this.request( + () => + this.api.startOneClickAppDeploy(template, values, templateName), + 'starting one-click application deployment' + ) + } + + getOneClickAppDeployProgress( + jobId: string + ): Promise { + return this.request( + () => this.api.getOneClickAppDeployProgress(jobId), + `retrieving one-click deployment progress ${jobId}` + ) + } + destroy(): void { this.api.destroy() } @@ -409,4 +485,8 @@ export type { Theme, UnusedImage, VersionInfo, + OneClickAppDefinitionResponse, + OneClickAppRepositories, + OneClickAppsResponse, + OneClickDeploymentState, } diff --git a/src/helpers/one-click.ts b/src/helpers/one-click.ts new file mode 100644 index 0000000..c7e5213 --- /dev/null +++ b/src/helpers/one-click.ts @@ -0,0 +1,107 @@ +import { OneClickDeploymentState } from '../clients/caprover' +import { withTimeout } from './retry' + +export interface OneClickProgressSnapshot { + currentStep: number + steps: string[] + error?: string + successMessage?: string +} + +export interface OneClickDeploymentResult { + outcome: 'success' | 'error' + state: OneClickDeploymentState + history: OneClickProgressSnapshot[] +} + +interface OneClickPollOptions { + timeoutMs?: number + intervalMs?: number +} + +export async function waitForOneClickDeployment( + getProgress: () => Promise, + options: OneClickPollOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 120_000 + const intervalMs = options.intervalMs ?? 500 + const deadline = Date.now() + timeoutMs + const history: OneClickProgressSnapshot[] = [] + let previousStep = -1 + + while (true) { + const remainingBeforeRequest = deadline - Date.now() + if (remainingBeforeRequest <= 0) throw timeoutError(timeoutMs, history) + + const state = await withTimeout( + getProgress(), + remainingBeforeRequest, + 'One-click deployment progress request' + ) + validateProgress(state, previousStep) + previousStep = state.currentStep + history.push({ + currentStep: state.currentStep, + steps: [...state.steps], + ...(state.error ? { error: state.error } : {}), + ...(state.successMessage !== undefined + ? { successMessage: state.successMessage } + : {}), + }) + + if (state.error) + return { + outcome: 'error', + state, + history, + } + + if (state.currentStep >= state.steps.length) + return { + outcome: 'success', + state, + history, + } + + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) throw timeoutError(timeoutMs, history) + await delay(Math.min(intervalMs, remainingMs)) + } +} + +function timeoutError( + timeoutMs: number, + history: OneClickProgressSnapshot[] +): Error { + return new Error( + `Timed out after ${timeoutMs}ms waiting for one-click deployment. Last progress: ${JSON.stringify(history.at(-1))}` + ) +} + +function validateProgress( + state: OneClickDeploymentState, + previousStep: number +): void { + if (!Array.isArray(state.steps) || state.steps.length === 0) { + throw new Error('One-click deployment progress had no steps') + } + if (!Number.isInteger(state.currentStep) || state.currentStep < 0) { + throw new Error( + `One-click deployment progress had invalid currentStep: ${state.currentStep}` + ) + } + if (state.currentStep > state.steps.length) { + throw new Error( + `One-click deployment progress exceeded its step count: ${state.currentStep}/${state.steps.length}` + ) + } + if (state.currentStep < previousStep) { + throw new Error( + `One-click deployment progress regressed from ${previousStep} to ${state.currentStep}` + ) + } +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} diff --git a/src/helpers/source-fixture.ts b/src/helpers/source-fixture.ts index b505187..98477b1 100644 --- a/src/helpers/source-fixture.ts +++ b/src/helpers/source-fixture.ts @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' +export interface OneClickRepositoryFixtureData { + list: { oneClickApps: unknown[] } + templates: Record +} + export async function sourceArchive( marker: string, alternate = false @@ -50,30 +55,57 @@ export async function customPortsSourceArchive(): Promise { return fixtureArchive('custom-ports', 'custom-ports-app') } +export async function oneClickRepositorySourceArchive( + data: OneClickRepositoryFixtureData +): Promise { + const temporary = await mkdtemp(join(tmpdir(), 'caprover-e2e-one-click-')) + try { + const source = join(temporary, 'source') + await cp( + join(process.cwd(), 'tests/fixtures/one-click-repository-app'), + source, + { recursive: true } + ) + await writeFile( + join(source, 'repository-data.json'), + `${JSON.stringify(data, null, 2)}\n` + ) + return await archiveFixture( + source, + 'one-click-repository-source.tar', + temporary + ) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + async function fixtureArchive( name: string, fixtureDirectory: string ): Promise { const temporary = await mkdtemp(join(tmpdir(), `caprover-e2e-${name}-`)) try { - const archive = join(temporary, 'source.tar') - await promisify(execFile)( - 'tar', - [ - '-cf', - archive, - '-C', - join(process.cwd(), 'tests/fixtures', fixtureDirectory), - '.', - ], - { timeout: 10_000 } - ) - return new File( - [new Uint8Array(await readFile(archive))], + return await archiveFixture( + join(process.cwd(), 'tests/fixtures', fixtureDirectory), `${name}-source.tar`, - { type: 'application/x-tar' } + temporary ) } finally { await rm(temporary, { recursive: true, force: true }) } } + +async function archiveFixture( + source: string, + fileName: string, + destinationDirectory: string +): Promise { + const archive = join(destinationDirectory, fileName) + await promisify(execFile)('tar', ['-cf', archive, '-C', source, '.'], { + timeout: 10_000, + }) + return new File([new Uint8Array(await readFile(archive))], fileName, { + type: 'application/x-tar', + }) +} diff --git a/tests/disk-cleanup.test.ts b/tests/disk-cleanup.test.ts index bc38350..21f1b6d 100644 --- a/tests/disk-cleanup.test.ts +++ b/tests/disk-cleanup.test.ts @@ -165,19 +165,14 @@ async function listImageIds(context: TestContext): Promise { } async function getServiceImages(context: TestContext): Promise { - const serviceIds = lines(await exec(context, 'docker service ls --quiet')) - for (const serviceId of serviceIds) { - if (!/^[a-z0-9]{25}$/.test(serviceId)) { - throw new Error( - `Docker returned an invalid service ID: ${serviceId}` - ) - } - } - if (serviceIds.length === 0) return [] + const serviceNames = lines( + await exec(context, "docker service ls --format '{{.Name}}'") + ) + if (serviceNames.length === 0) return [] return lines( await exec( context, - `docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' ${serviceIds.map(shellQuote).join(' ')}` + `docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' ${serviceNames.map(shellQuote).join(' ')}` ) ) } diff --git a/tests/fixtures/one-click-repository-app/Dockerfile b/tests/fixtures/one-click-repository-app/Dockerfile new file mode 100644 index 0000000..f868e20 --- /dev/null +++ b/tests/fixtures/one-click-repository-app/Dockerfile @@ -0,0 +1,5 @@ +FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd +WORKDIR /app +COPY server.js repository-data.json ./ +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/tests/fixtures/one-click-repository-app/captain-definition b/tests/fixtures/one-click-repository-app/captain-definition new file mode 100644 index 0000000..b787968 --- /dev/null +++ b/tests/fixtures/one-click-repository-app/captain-definition @@ -0,0 +1 @@ +{"schemaVersion":2,"dockerfilePath":"./Dockerfile"} diff --git a/tests/fixtures/one-click-repository-app/server.js b/tests/fixtures/one-click-repository-app/server.js new file mode 100644 index 0000000..6e040fe --- /dev/null +++ b/tests/fixtures/one-click-repository-app/server.js @@ -0,0 +1,41 @@ +const { createServer } = require('node:http') +const data = require('./repository-data.json') + +const server = createServer((request, response) => { + const url = new URL(request.url, 'http://repository.local') + if (request.method !== 'GET') { + response.writeHead(405) + response.end() + return + } + + if (url.pathname === '/v4/list') { + sendJson(response, 200, data.list) + return + } + + const prefix = '/v4/apps/' + if (url.pathname.startsWith(prefix)) { + let templateName + try { + templateName = decodeURIComponent(url.pathname.slice(prefix.length)) + } catch { + sendJson(response, 400, { error: 'invalid template path' }) + return + } + const template = data.templates[templateName] + if (template) { + sendJson(response, 200, template) + return + } + } + + sendJson(response, 404, { error: 'template not found' }) +}) + +server.listen(8080, '0.0.0.0') + +function sendJson(response, status, value) { + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(JSON.stringify(value)) +} diff --git a/tests/one-click-repositories.test.ts b/tests/one-click-repositories.test.ts new file mode 100644 index 0000000..9b91b71 --- /dev/null +++ b/tests/one-click-repositories.test.ts @@ -0,0 +1,250 @@ +import { CapRoverModels } from 'caprover-api' +import { expect, test } from 'vitest' +import { CleanupRegistry } from '../src/helpers/cleanup' +import { + nextVersion, + waitForDeployment, + waitForServiceStable, +} from '../src/helpers/deployment' +import { waitForOneClickDeployment } from '../src/helpers/one-click' +import { createTestNames } from '../src/helpers/names' +import { eventually } from '../src/helpers/retry' +import { + oneClickRepositorySourceArchive, + OneClickRepositoryFixtureData, +} from '../src/helpers/source-fixture' +import { cleanUpApp, withTestContext } from '../src/helpers/test-context' +import { requireEphemeral } from '../src/test-selection' + +const NGINX_IMAGE = + 'nginx:1.29.8-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de' + +test('custom one-click repositories list, fetch, deploy, reject duplicates, and clean up', async () => { + requireEphemeral() + await withTestContext(async (context, cleanup, rootDomain) => { + const { runId } = createTestNames() + const repositoryAppName = `e2e-${runId}-oneclick-repo` + const deployedAppName = `e2e-${runId}-oneclick-template` + const templateName = `e2e-template-${runId}` + const marker = `repository-template-${runId}` + const repositoryBaseUrl = `http://${repositoryAppName}.${rootDomain}` + const api = context.caprover + const repositoryData = fixtureData(templateName) + + cleanUpApp(context, cleanup, repositoryAppName) + cleanUpRepository(api, cleanup, repositoryBaseUrl) + + await api.createApp(repositoryAppName) + await api.updateApp(repositoryAppName, { containerHttpPort: 8080 }) + const repositoryVersion = nextVersion( + await api.getApp(repositoryAppName) + ) + await api.uploadSource( + repositoryAppName, + await oneClickRepositorySourceArchive(repositoryData), + false + ) + await waitForDeployment(context, repositoryAppName, repositoryVersion) + await waitForServiceStable(context, repositoryAppName) + await eventually( + async () => { + const response = await context.http.get( + `${repositoryBaseUrl}/v4/list` + ) + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual(repositoryData.list) + }, + { + description: + 'test-owned one-click repository to become reachable', + } + ) + + await api.addCustomOneClickRepo(`${repositoryBaseUrl}/`) + await eventually( + async () => { + const repositories = await api.getAllOneClickAppRepos() + expect( + repositories.urls.filter((url) => url === repositoryBaseUrl) + ).toHaveLength(1) + expect(repositories.urls).not.toContain(`${repositoryBaseUrl}/`) + }, + { + description: + 'custom one-click repository to persist canonically', + } + ) + + const listed = await api.getAllOneClickApps() + const identifier = listed.oneClickApps.find( + (item) => + item.baseUrl === repositoryBaseUrl && item.name === templateName + ) + expect(identifier).toMatchObject({ + baseUrl: repositoryBaseUrl, + name: templateName, + displayName: 'CapRover E2E repository fixture', + description: 'A deterministic one-click repository fixture', + isOfficial: false, + logoUrl: `${repositoryBaseUrl}/v4/logos/fixture.svg`, + }) + + const fetched = await api.getOneClickAppByName( + templateName, + repositoryBaseUrl + ) + // caprover-api#11 tracks the SDK's string type for this backend object. + const fetchedTemplate = + fetched.appTemplate as unknown as CapRoverModels.IOneClickTemplate + expect(fetchedTemplate).toEqual(repositoryData.templates[templateName]) + await expect( + api.addCustomOneClickRepo(repositoryBaseUrl) + ).rejects.toMatchObject({ captainStatus: 1110 }) + + cleanUpApp(context, cleanup, deployedAppName) + const deploymentTemplate = augmentBuiltInVariables(fetchedTemplate) + const job = await api.startOneClickAppDeploy( + deploymentTemplate, + [ + { key: '$$cap_appname', value: deployedAppName }, + { key: '$$cap_root_domain', value: rootDomain }, + { key: '$$cap_marker', value: marker }, + ], + templateName + ) + drainJob(api, cleanup, job.jobId) + const deployed = await waitForOneClickDeployment(() => + api.getOneClickAppDeployProgress(job.jobId) + ) + expect(deployed.outcome).toBe('success') + expect(deployed.state.successMessage).toContain(marker) + + expect((await api.getApp(deployedAppName)).envVars).toEqual( + expect.arrayContaining([{ key: 'E2E_MARKER', value: marker }]) + ) + await eventually( + async () => { + expect( + context.docker.imageMatches( + await context.docker.getServiceImage(deployedAppName), + NGINX_IMAGE + ) + ).toBe(true) + expect( + await context.docker.getRunningReplicas(deployedAppName) + ).toBe(1) + expect( + await context.docker.getServiceEnvironment(deployedAppName) + ).toEqual(expect.arrayContaining([`E2E_MARKER=${marker}`])) + }, + { + description: + 'deployed custom one-click application Docker state', + } + ) + await context.http.waitUntilReachable( + `http://${deployedAppName}.${rootDomain}`, + marker, + 60_000 + ) + + await api.deleteCustomOneClickRepo(repositoryBaseUrl) + expect((await api.getAllOneClickAppRepos()).urls).not.toContain( + repositoryBaseUrl + ) + await expect( + api.deleteCustomOneClickRepo(repositoryBaseUrl) + ).rejects.toMatchObject({ captainStatus: 1110 }) + await expect( + api.getOneClickAppByName(templateName, repositoryBaseUrl) + ).rejects.toMatchObject({ captainStatus: 1110 }) + }) +}) + +function fixtureData(templateName: string): OneClickRepositoryFixtureData { + const template: CapRoverModels.IOneClickTemplate = { + captainVersion: 4, + services: { + $$cap_appname: { + image: NGINX_IMAGE, + environment: { E2E_MARKER: '$$cap_marker' }, + command: [ + '/bin/sh', + '-c', + "printf '%s' '$$cap_marker' > /usr/share/nginx/html/index.html && exec nginx -g 'daemon off;'", + ], + }, + }, + caproverOneClickApp: { + variables: [{ id: '$$cap_marker', label: 'Fixture marker' }], + instructions: { + start: 'Deploying a custom repository fixture', + end: 'Custom repository fixture complete: $$cap_marker', + }, + displayName: 'CapRover E2E repository fixture', + }, + } + return { + list: { + oneClickApps: [ + { + name: templateName, + displayName: 'CapRover E2E repository fixture', + description: 'A deterministic one-click repository fixture', + logoUrl: 'fixture.svg', + isOfficial: false, + }, + ], + }, + templates: { [templateName]: template }, + } +} + +function augmentBuiltInVariables( + template: CapRoverModels.IOneClickTemplate +): CapRoverModels.IOneClickTemplate { + const cloned = JSON.parse( + JSON.stringify(template) + ) as CapRoverModels.IOneClickTemplate + cloned.caproverOneClickApp.variables.unshift({ + id: '$$cap_appname', + label: 'App Name', + }) + cloned.caproverOneClickApp.variables.push({ + id: '$$cap_root_domain', + label: 'CapRover root domain', + }) + return cloned +} + +function cleanUpRepository( + api: import('../src/clients/caprover').CapRoverClient, + cleanup: CleanupRegistry, + repositoryUrl: string +): void { + cleanup.add(async () => { + const canonicalUrl = withoutTrailingSlashes(repositoryUrl) + const storedUrls = (await api.getAllOneClickAppRepos()).urls.filter( + (url) => withoutTrailingSlashes(url) === canonicalUrl + ) + for (const storedUrl of storedUrls) + await api.deleteCustomOneClickRepo(storedUrl) + }) +} + +function withoutTrailingSlashes(url: string): string { + return url.replace(/\/+$/, '') +} + +function drainJob( + api: import('../src/clients/caprover').CapRoverClient, + cleanup: CleanupRegistry, + jobId: string +): void { + cleanup.add(async () => { + await waitForOneClickDeployment( + () => api.getOneClickAppDeployProgress(jobId), + { timeoutMs: 120_000 } + ) + }) +} diff --git a/tests/one-click.test.ts b/tests/one-click.test.ts new file mode 100644 index 0000000..e6f811c --- /dev/null +++ b/tests/one-click.test.ts @@ -0,0 +1,265 @@ +import { CapRoverModels } from 'caprover-api' +import { expect, test } from 'vitest' +import { RawApiClient } from '../src/clients/raw-api' +import { CleanupRegistry } from '../src/helpers/cleanup' +import { waitForOneClickDeployment } from '../src/helpers/one-click' +import { createTestNames } from '../src/helpers/names' +import { eventually } from '../src/helpers/retry' +import { cleanUpApp, withTestContext } from '../src/helpers/test-context' +import { loadConfig } from '../src/config' + +const NGINX_IMAGE = + 'nginx:1.29.8-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de' + +test('one-click deployment configures dependent services, a project, and public routing', async () => { + await withTestContext(async (context, cleanup, rootDomain) => { + const { runId } = createTestNames() + const appName = `e2e-${runId}-oneclick` + const dependencyName = `${appName}-dependency` + const marker = `oneclick-${runId}` + const api = context.caprover + + cleanUpProject(api, cleanup, appName) + cleanUpApp(context, cleanup, dependencyName) + cleanUpApp(context, cleanup, appName) + + const job = await api.startOneClickAppDeploy( + dependentTemplate(), + [ + { key: '$$cap_appname', value: appName }, + { key: '$$cap_root_domain', value: rootDomain }, + { key: '$$cap_http_marker', value: marker }, + { key: '$$cap_environment_marker', value: marker }, + ], + 'TEMPLATE_ONE_CLICK' + ) + expect(job.jobId).toMatch(/^deploy_/) + drainJob(api, cleanup, job.jobId) + + const result = await waitForOneClickDeployment(() => + api.getOneClickAppDeployProgress(job.jobId) + ) + expect(result.outcome).toBe('success') + expect(result.state.error ?? '').toBe('') + expect(result.state.currentStep).toBe(result.state.steps.length) + expect(result.state.successMessage).toContain(marker) + expect(result.history.map((state) => state.currentStep)).toEqual( + [...result.history] + .map((state) => state.currentStep) + .sort((left, right) => left - right) + ) + expectStepOrder( + result.state.steps, + `Registering ${dependencyName}`, + `Registering ${appName}` + ) + expectStepOrder( + result.state.steps, + `Deploying ${dependencyName} (might take up to a minute)`, + `Deploying ${appName} (might take up to a minute)` + ) + + const [webApp, dependencyApp, projects] = await Promise.all([ + api.getApp(appName), + api.getApp(dependencyName), + api.getProjects(), + ]) + const project = projects.projects.find((item) => item.name === appName) + expect(project, 'the one-click project exists').toBeDefined() + if (!project) throw new Error(`One-click project ${appName} was absent`) + expect(webApp.projectId).toBe(project.id) + expect(dependencyApp.projectId).toBe(project.id) + expect(webApp.tags).toEqual( + expect.arrayContaining([{ tagName: appName }]) + ) + expect(dependencyApp.tags).toEqual( + expect.arrayContaining([{ tagName: appName }]) + ) + expect(webApp.envVars).toEqual( + expect.arrayContaining([ + { key: 'E2E_MARKER', value: marker }, + { key: 'ROOT_DOMAIN', value: rootDomain }, + { + key: 'DEPENDENCY_HOST', + value: `srv-captain--${dependencyName}`, + }, + ]) + ) + expect(dependencyApp.envVars).toEqual( + expect.arrayContaining([{ key: 'E2E_MARKER', value: marker }]) + ) + expect(dependencyApp.notExposeAsWebApp).toBe(true) + + await eventually( + async () => { + for (const name of [dependencyName, appName]) { + expect(await context.docker.getDesiredReplicas(name)).toBe( + 1 + ) + expect(await context.docker.getRunningReplicas(name)).toBe( + 1 + ) + expect( + context.docker.imageMatches( + await context.docker.getServiceImage(name), + NGINX_IMAGE + ) + ).toBe(true) + } + expect( + await context.docker.getServiceEnvironment(appName) + ).toEqual( + expect.arrayContaining([ + `E2E_MARKER=${marker}`, + `ROOT_DOMAIN=${rootDomain}`, + `DEPENDENCY_HOST=srv-captain--${dependencyName}`, + ]) + ) + expect( + await context.docker.getServiceEnvironment(dependencyName) + ).toEqual(expect.arrayContaining([`E2E_MARKER=${marker}`])) + }, + { timeoutMs: 60_000, description: 'one-click Docker service state' } + ) + + const webUrl = `http://${appName}.${rootDomain}` + await context.http.waitUntilReachable(webUrl, marker, 60_000) + }) +}) + +test('one-click deployment progress reports asynchronous template errors', async () => { + await withTestContext(async (context, cleanup) => { + const api = context.caprover + const job = await api.startOneClickAppDeploy( + cyclicTemplate(), + [], + 'TEMPLATE_ONE_CLICK' + ) + drainJob(api, cleanup, job.jobId) + const result = await waitForOneClickDeployment(() => + api.getOneClickAppDeployProgress(job.jobId) + ) + expect(result.outcome).toBe('error') + expect(result.state.error).toContain( + 'Dependency tree cannot be resolved' + ) + }) +}) + +test('one-click deployment and job validation use the documented parameter status', async () => { + const config = loadConfig() + const raw = new RawApiClient(config.caproverUrl) + await raw.login(config.caproverPassword) + + expect( + ( + await raw.request('POST', '/user/oneclick/deploy', { + values: [], + templateName: 'TEMPLATE_ONE_CLICK', + }) + ).status + ).toBe(1110) + + await withTestContext(async (context) => { + await expect( + context.caprover.getOneClickAppDeployProgress('') + ).rejects.toMatchObject({ captainStatus: 1110 }) + await expect( + context.caprover.getOneClickAppDeployProgress( + `deploy-e2e-${createTestNames().runId}` + ) + ).rejects.toMatchObject({ captainStatus: 1110 }) + }) +}) + +function dependentTemplate(): CapRoverModels.IOneClickTemplate { + return { + captainVersion: 4, + services: { + '$$cap_appname-dependency': { + image: NGINX_IMAGE, + environment: { E2E_MARKER: '$$cap_environment_marker' }, + caproverExtra: { + containerHttpPort: 80, + notExposeAsWebApp: true, + websocketSupport: false, + }, + }, + $$cap_appname: { + image: NGINX_IMAGE, + depends_on: ['$$cap_appname-dependency'], + environment: { + E2E_MARKER: '$$cap_environment_marker', + ROOT_DOMAIN: '$$cap_root_domain', + DEPENDENCY_HOST: 'srv-captain--$$cap_appname-dependency', + }, + command: [ + '/bin/sh', + '-c', + "printf '%s' '$$cap_http_marker' > /usr/share/nginx/html/index.html && exec nginx -g 'daemon off;'", + ], + }, + }, + caproverOneClickApp: { + variables: [ + { id: '$$cap_appname', label: 'App Name' }, + { id: '$$cap_root_domain', label: 'Root Domain' }, + { id: '$$cap_http_marker', label: 'HTTP marker' }, + { id: '$$cap_environment_marker', label: 'Environment marker' }, + ], + instructions: { + start: 'Deploying a deterministic E2E fixture', + end: 'One-click fixture complete: $$cap_http_marker', + }, + displayName: 'CapRover E2E one-click fixture', + }, + } +} + +function cyclicTemplate(): CapRoverModels.IOneClickTemplate { + return { + captainVersion: 4, + services: { + first: { image: NGINX_IMAGE, depends_on: ['second'] }, + second: { image: NGINX_IMAGE, depends_on: ['first'] }, + }, + caproverOneClickApp: { + variables: [], + instructions: { start: '', end: '' }, + displayName: 'Cyclic E2E fixture', + }, + } +} + +function cleanUpProject( + api: import('../src/clients/caprover').CapRoverClient, + cleanup: CleanupRegistry, + name: string +): void { + cleanup.add(async () => { + const { projects } = await api.getProjects() + for (const project of projects.filter((item) => item.name === name)) + await api.deleteProject(project.id) + }) +} + +function drainJob( + api: import('../src/clients/caprover').CapRoverClient, + cleanup: CleanupRegistry, + jobId: string +): void { + cleanup.add(async () => { + await waitForOneClickDeployment( + () => api.getOneClickAppDeployProgress(jobId), + { timeoutMs: 120_000 } + ) + }) +} + +function expectStepOrder(steps: string[], before: string, after: string): void { + const beforeIndex = steps.indexOf(before) + const afterIndex = steps.indexOf(after) + expect(beforeIndex, `progress contains ${before}`).toBeGreaterThanOrEqual(0) + expect(afterIndex, `progress contains ${after}`).toBeGreaterThanOrEqual(0) + expect(beforeIndex).toBeLessThan(afterIndex) +} diff --git a/tests/unit/caprover-client.test.ts b/tests/unit/caprover-client.test.ts index e356907..29bc7f7 100644 --- a/tests/unit/caprover-client.test.ts +++ b/tests/unit/caprover-client.test.ts @@ -262,3 +262,84 @@ test('disk-cleanup and global Nginx operations use the SDK parameters', async () vi.restoreAllMocks() } }) + +test('one-click operations use the SDK parameters and always receive a values array', async () => { + const template = { + captainVersion: 4, + services: {}, + caproverOneClickApp: { + variables: [], + instructions: { start: '', end: '' }, + displayName: 'fixture', + }, + } + const values = [{ key: '$$cap_appname', value: 'fixture-app' }] + const apps = vi + .spyOn(CapRoverAPI.prototype, 'getAllOneClickApps') + .mockResolvedValue({ oneClickApps: [] }) + const definition = vi + .spyOn(CapRoverAPI.prototype, 'getOneClickAppByName') + .mockResolvedValue({ appTemplate: template } as never) + const repositories = vi + .spyOn(CapRoverAPI.prototype, 'getAllOneClickAppRepos') + .mockResolvedValue({ urls: [] }) + const insert = vi + .spyOn(CapRoverAPI.prototype, 'addNewCustomOneClickRepo') + .mockResolvedValue(undefined) + const remove = vi + .spyOn(CapRoverAPI.prototype, 'deleteCustomOneClickRepo') + .mockResolvedValue(undefined) + const start = vi + .spyOn(CapRoverAPI.prototype, 'startOneClickAppDeploy') + .mockResolvedValue({ jobId: 'deploy-test' }) + const progress = vi + .spyOn(CapRoverAPI.prototype, 'getOneClickAppDeployProgress') + .mockResolvedValue({ + steps: ['done'], + currentStep: 1, + error: '', + successMessage: 'done', + }) + const client = new CapRoverClient('https://example.test', 'password') + try { + await expect(client.getAllOneClickApps()).resolves.toEqual({ + oneClickApps: [], + }) + await expect( + client.getOneClickAppByName('fixture', 'https://repo.example') + ).resolves.toEqual({ appTemplate: template }) + await expect(client.getAllOneClickAppRepos()).resolves.toEqual({ + urls: [], + }) + await client.addCustomOneClickRepo('https://repo.example') + await client.deleteCustomOneClickRepo('https://repo.example') + await expect( + client.startOneClickAppDeploy( + template, + values, + 'TEMPLATE_ONE_CLICK' + ) + ).resolves.toEqual({ jobId: 'deploy-test' }) + await expect( + client.getOneClickAppDeployProgress('deploy-test') + ).resolves.toMatchObject({ currentStep: 1 }) + + expect(apps).toHaveBeenCalledExactlyOnceWith() + expect(definition).toHaveBeenCalledExactlyOnceWith( + 'fixture', + 'https://repo.example' + ) + expect(repositories).toHaveBeenCalledExactlyOnceWith() + expect(insert).toHaveBeenCalledExactlyOnceWith('https://repo.example') + expect(remove).toHaveBeenCalledExactlyOnceWith('https://repo.example') + expect(start).toHaveBeenCalledExactlyOnceWith( + template, + values, + 'TEMPLATE_ONE_CLICK' + ) + expect(progress).toHaveBeenCalledExactlyOnceWith('deploy-test') + } finally { + client.destroy() + vi.restoreAllMocks() + } +}) diff --git a/tests/unit/one-click.test.ts b/tests/unit/one-click.test.ts new file mode 100644 index 0000000..c28e880 --- /dev/null +++ b/tests/unit/one-click.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' +import { waitForOneClickDeployment } from '../../src/helpers/one-click' + +test('one-click polling returns a successful monotonic terminal state', async () => { + const states = [ + { steps: ['queue', 'deploy'], currentStep: 0, error: '' }, + { steps: ['queue', 'deploy'], currentStep: 1, error: '' }, + { + steps: ['queue', 'deploy'], + currentStep: 2, + error: '', + successMessage: 'complete', + }, + ] + const result = await waitForOneClickDeployment( + async () => states.shift()!, + { intervalMs: 0 } + ) + expect(result.outcome).toBe('success') + expect(result.history.map((state) => state.currentStep)).toEqual([0, 1, 2]) +}) + +test('one-click polling returns an asynchronous deployment error', async () => { + const result = await waitForOneClickDeployment( + async () => ({ + steps: ['Parsing the template'], + currentStep: 0, + error: 'Cannot parse the template', + }), + { intervalMs: 0 } + ) + expect(result.outcome).toBe('error') + expect(result.state.error).toContain('Cannot parse') +}) + +test('one-click polling accepts a terminal state with an empty success message', async () => { + const result = await waitForOneClickDeployment( + async () => ({ + steps: ['deploy'], + currentStep: 1, + error: '', + successMessage: '', + }), + { intervalMs: 0 } + ) + expect(result.outcome).toBe('success') + expect(result.history[0].successMessage).toBe('') +}) + +test('one-click polling applies its timeout to a hanging progress request', async () => { + const startedAt = Date.now() + await expect( + waitForOneClickDeployment(() => new Promise(() => {}), { + timeoutMs: 20, + intervalMs: 0, + }) + ).rejects.toThrow( + 'One-click deployment progress request timed out after 20ms' + ) + expect(Date.now() - startedAt).toBeLessThan(500) +}) + +test('one-click polling rejects a regressing progress state', async () => { + const states = [ + { steps: ['queue', 'deploy'], currentStep: 1, error: '' }, + { steps: ['queue', 'deploy'], currentStep: 0, error: '' }, + ] + await expect( + waitForOneClickDeployment(async () => states.shift()!, { + intervalMs: 0, + }) + ).rejects.toThrow('regressed from 1 to 0') +}) diff --git a/tests/unit/source-fixture.test.ts b/tests/unit/source-fixture.test.ts index 5fb8dc5..c91d9f4 100644 --- a/tests/unit/source-fixture.test.ts +++ b/tests/unit/source-fixture.test.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util' import { expect, test } from 'vitest' import { customPortsSourceArchive, + oneClickRepositorySourceArchive, routingSourceArchive, sourceArchive, } from '../../src/helpers/source-fixture' @@ -87,3 +88,33 @@ test('custom-port source tar contains the TCP and UDP echo fixture', async () => await rm(temporary, { recursive: true, force: true }) } }) + +test('one-click repository source tar contains generated repository data', async () => { + const archive = await oneClickRepositorySourceArchive({ + list: { oneClickApps: [{ name: 'fixture-template' }] }, + templates: { 'fixture-template': { captainVersion: 4 } }, + }) + const temporary = await mkdtemp( + join(tmpdir(), 'caprover-one-click-repository-test-') + ) + try { + const path = join(temporary, 'fixture.tar') + await writeFile(path, new Uint8Array(await archive.arrayBuffer())) + const { stdout } = await promisify(execFile)('tar', ['-tf', path]) + expect(stdout).toContain('./captain-definition') + expect(stdout).toContain('./Dockerfile') + expect(stdout).toContain('./server.js') + expect(stdout).toContain('./repository-data.json') + const data = await promisify(execFile)('tar', [ + '-xOf', + path, + './repository-data.json', + ]) + expect(JSON.parse(data.stdout)).toEqual({ + list: { oneClickApps: [{ name: 'fixture-template' }] }, + templates: { 'fixture-template': { captainVersion: 4 } }, + }) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +})