diff --git a/docs/deploying.md b/docs/deploying.md index 6beb62651..7113a0811 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -36,9 +36,11 @@ npm run deploy -- --help -## Automated deploys +## Continuous deployment -After deploying an app manually at least once, Observable can handle subsequent deploys for you automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly). + + +You can connect your app to Observable to handle deploys automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly). Automatic deploys — also called _continuous deployment_ or _CD_ — ensure that your data is always up to date, and that any changes you make to your app are immediately reflected in the deployed version. @@ -88,7 +90,7 @@ To create an API key: 1. Open the [API Key settings](https://observablehq.com/select-workspace?next=api-keys-settings) for your Observable workspace. 2. Click **New API Key**. -3. Check the **Deploy new versions of projects** checkbox. +3. Check the **Deploy new versions of data apps** checkbox. 4. Give your key a description, such as “Deploy via GitHub Actions”. 5. Click **Create API Key**. @@ -147,7 +149,8 @@ The contents of the deploy config file look like this: { "projectId": "0123456789abcdef", "projectSlug": "hello-framework", - "workspaceLogin": "acme" + "workspaceLogin": "acme", + "continuousDeployment": true } ``` diff --git a/src/deploy.ts b/src/deploy.ts index dbf19540d..7c0fcbc61 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,7 +1,10 @@ +import {exec} from "node:child_process"; import {createHash} from "node:crypto"; import type {Stats} from "node:fs"; +import {existsSync} from "node:fs"; import {readFile, stat} from "node:fs/promises"; import {join} from "node:path/posix"; +import {promisify} from "node:util"; import slugify from "@sindresorhus/slugify"; import wrapAnsi from "wrap-ansi"; import type {BuildEffects, BuildManifest, BuildOptions} from "./build.js"; @@ -20,6 +23,7 @@ import type { DeployManifestFile, GetCurrentUserResponse, GetDeployResponse, + GetProjectEnvironmentResponse, GetProjectResponse, WorkspaceResponse } from "./observableApiClient.js"; @@ -33,6 +37,10 @@ const DEPLOY_POLL_MAX_MS = 1000 * 60 * 5; const DEPLOY_POLL_INTERVAL_MS = 1000 * 5; const BUILD_AGE_WARNING_MS = 1000 * 60 * 5; +export function formatGitUrl(url: string) { + return new URL(url).pathname.slice(1).replace(/\.git$/, ""); +} + export interface DeployOptions { config: Config; deployConfigPath: string | undefined; @@ -82,9 +90,14 @@ const defaultEffects: DeployEffects = { type DeployTargetInfo = | {create: true; workspace: {id: string; login: string}; projectSlug: string; title: string; accessLevel: string} - | {create: false; workspace: {id: string; login: string}; project: GetProjectResponse}; - -/** Deploy a project to ObservableHQ */ + | { + create: false; + workspace: {id: string; login: string}; + project: GetProjectResponse; + environment: GetProjectEnvironmentResponse; + }; + +/** Deploy a project to Observable */ export async function deploy(deployOptions: DeployOptions, effects = defaultEffects): Promise { Telemetry.record({event: "deploy", step: "start", force: deployOptions.force}); effects.clack.intro(`${inverse(" observable deploy ")} ${faint(`v${process.env.npm_package_version}`)}`); @@ -190,14 +203,128 @@ class Deployer { return deployInfo; } - private async startNewDeploy(): Promise { - const deployConfig = await this.getUpdatedDeployConfig(); - const deployTarget = await this.getDeployTarget(deployConfig); - const buildFilePaths = await this.getBuildFilePaths(); - const deployId = await this.createNewDeploy(deployTarget); + private async cloudBuild(deployTarget: DeployTargetInfo) { + if (deployTarget.create) { + throw new Error("Incorrect deployTarget state"); + } + const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions; + await this.apiClient.postProjectBuild(deployTarget.project.id); + const spinner = this.effects.clack.spinner(); + spinner.start("Requesting deploy"); + const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; + while (true) { + if (Date.now() > pollExpiration) { + spinner.stop("Requesting deploy timed out."); + throw new CliError("Requesting deploy failed"); + } + const {latestCreatedDeployId} = await this.apiClient.getProject({ + workspaceLogin: deployTarget.workspace.login, + projectSlug: deployTarget.project.slug + }); + if (latestCreatedDeployId !== deployTarget.project.latestCreatedDeployId) { + spinner.stop( + `Deploy started. Watch logs: ${process.env["OBSERVABLE_ORIGIN"] ?? "https://observablehq.com"}/projects/@${ + deployTarget.workspace.login + }/${deployTarget.project.slug}/deploys/${latestCreatedDeployId}` + ); + // latestCreatedDeployId is initially null for a new project, but once + // it changes to a string it can never change back; since we know it has + // changed, we assert here that it’s not null + return latestCreatedDeployId!; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } - await this.uploadFiles(deployId, buildFilePaths); - await this.markDeployUploaded(deployId); + private async maybeLinkGitHub(deployTarget: DeployTargetInfo): Promise { + if (deployTarget.create) { + throw new Error("Incorrect deployTarget state"); + } + if (!this.effects.isTty) return false; + if (deployTarget.environment.build_environment_id && deployTarget.environment.source) { + // can do cloud build + return true; + } else { + // We only support cloud builds from the root directory so this ignores this.deployOptions.config.root + const isGit = existsSync(".git"); + if (isGit) { + const remotes = (await promisify(exec)("git remote -v", {cwd: this.deployOptions.config.root})).stdout + .split("\n") + .filter((d) => d) + .map((d) => d.split(/\s/g)); + const gitHub = remotes.find(([, url]) => url.startsWith("https://github.com/")); + if (gitHub) { + const repoName = formatGitUrl(gitHub[1]); + const repositories = (await this.apiClient.getGitHubRepositories())?.repositories; + const authedRepo = repositories?.find(({url}) => formatGitUrl(url) === repoName); + if (authedRepo) { + // Set branch to current branch + const branch = ( + await promisify(exec)("git rev-parse --abbrev-ref HEAD", {cwd: this.deployOptions.config.root}) + ).stdout; + await this.apiClient.postProjectEnvironment(deployTarget.project.id, { + source: { + provider: authedRepo.provider, + provider_id: authedRepo.provider_id, + url: authedRepo.url, + branch + } + }); + return true; + } else { + // repo not auth’ed; link to auth page and poll for auth + // TODO: link to internal page that bookends the flow and handles the no-oauth-token case more gracefully + this.effects.clack.log.info( + `Authorize Observable to access the ${bold(repoName)} repository: ${link( + "https://github.com/apps/observable-data-apps-dev/installations/select_target" + )}` + ); + const spinner = this.effects.clack.spinner(); + spinner.start("Waiting for repository to be authorized"); + const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; + while (true) { + if (Date.now() > pollExpiration) { + spinner.stop("Waiting for repository to be authorized timed out."); + throw new CliError("Repository authorization failed"); + } + const repositories = (await this.apiClient.getGitHubRepositories())?.repositories; + const authedRepo = repositories?.find(({url}) => formatGitUrl(url) === repoName); + if (authedRepo) { + spinner.stop("Repository authorized."); + await this.apiClient.postProjectEnvironment(deployTarget.project.id, { + source: { + provider: authedRepo.provider, + provider_id: authedRepo.provider_id, + url: authedRepo.url, + branch: null // TODO detect branch + } + }); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + } else { + this.effects.clack.log.error("No GitHub remote found; cannot enable continuous deployment."); + } + } else { + this.effects.clack.log.error("Not at root of a git repository; cannot enable continuous deployment."); + } + } + return false; + } + + private async startNewDeploy(): Promise { + const {deployConfig, deployTarget} = await this.getDeployTarget(await this.getUpdatedDeployConfig()); + let deployId: string | null; + if (deployConfig.continuousDeployment) { + deployId = await this.cloudBuild(deployTarget); + } else { + const buildFilePaths = await this.getBuildFilePaths(); + deployId = await this.createNewDeploy(deployTarget); + await this.uploadFiles(deployId, buildFilePaths); + await this.markDeployUploaded(deployId); + } return await this.pollForProcessingCompletion(deployId); } @@ -274,7 +401,9 @@ class Deployer { } // Get the deploy target, prompting the user as needed. - private async getDeployTarget(deployConfig: DeployConfig): Promise { + private async getDeployTarget( + deployConfig: DeployConfig + ): Promise<{deployTarget: DeployTargetInfo; deployConfig: DeployConfig}> { let deployTarget: DeployTargetInfo; if (deployConfig.workspaceLogin && deployConfig.projectSlug) { try { @@ -282,7 +411,8 @@ class Deployer { workspaceLogin: deployConfig.workspaceLogin, projectSlug: deployConfig.projectSlug }); - deployTarget = {create: false, workspace: project.owner, project}; + const environment = await this.apiClient.getProjectEnvironment({id: project.id}); + deployTarget = {create: false, workspace: project.owner, project, environment}; } catch (error) { if (!isHttpError(error) || error.statusCode !== 404) { throw error; @@ -360,7 +490,17 @@ class Deployer { workspaceId: deployTarget.workspace.id, accessLevel: deployTarget.accessLevel }); - deployTarget = {create: false, workspace: deployTarget.workspace, project}; + deployTarget = { + create: false, + workspace: deployTarget.workspace, + project, + // TODO: In the future we may have a default environment + environment: { + automatic_builds_enabled: null, + build_environment_id: null, + source: null + } + }; } catch (error) { if (isApiError(error) && error.details.errors.some((e) => e.code === "TOO_MANY_PROJECTS")) { this.effects.clack.log.error( @@ -384,18 +524,40 @@ class Deployer { } } + let {continuousDeployment} = deployConfig; + if (continuousDeployment === null) { + const enable = await this.effects.clack.confirm({ + message: wrapAnsi( + `Do you want to enable continuous deployment? ${faint( + "This builds in the cloud and redeploys whenever you push to this repository." + )}`, + this.effects.outputColumns + ), + active: "Yes, enable and build in cloud", + inactive: "No, build locally" + }); + if (this.effects.clack.isCancel(enable)) throw new CliError("User canceled deploy", {print: false, exitCode: 0}); + continuousDeployment = enable; + } + + // Disables continuous deployment if there’s no env/source & we can’t link GitHub + if (continuousDeployment) continuousDeployment = await this.maybeLinkGitHub(deployTarget); + + const newDeployConfig = { + projectId: deployTarget.project.id, + projectSlug: deployTarget.project.slug, + workspaceLogin: deployTarget.workspace.login, + continuousDeployment + }; + await this.effects.setDeployConfig( this.deployOptions.config.root, this.deployOptions.deployConfigPath, - { - projectId: deployTarget.project.id, - projectSlug: deployTarget.project.slug, - workspaceLogin: deployTarget.workspace.login - }, + newDeployConfig, this.effects ); - return deployTarget; + return {deployConfig: newDeployConfig, deployTarget}; } // Create the new deploy on the server. @@ -756,7 +918,17 @@ export async function promptDeployTarget( if (effects.clack.isCancel(chosenProject)) { throw new CliError("User canceled deploy.", {print: false, exitCode: 0}); } else if (chosenProject !== null) { - return {create: false, workspace, project: existingProjects.find((p) => p.slug === chosenProject)!}; + // TODO(toph): initial env config + return { + create: false, + workspace, + project: existingProjects.find((p) => p.slug === chosenProject)!, + environment: { + automatic_builds_enabled: null, + build_environment_id: null, + source: null + } + }; } } else { const confirmChoice = await effects.clack.confirm({ diff --git a/src/observableApiClient.ts b/src/observableApiClient.ts index fe8b2b38a..f97e30e08 100644 --- a/src/observableApiClient.ts +++ b/src/observableApiClient.ts @@ -126,6 +126,35 @@ export class ObservableApiClient { return await this._fetch(url, {method: "GET"}); } + async getProjectEnvironment({id}: {id: string}): Promise { + const url = new URL(`/cli/project/${id}/environment`, this._apiOrigin); + return await this._fetch(url, {method: "GET"}); + } + + async getGitHubRepositories(): Promise { + const url = new URL("/cli/github/repositories", this._apiOrigin); + try { + return await this._fetch(url, {method: "GET"}); + } catch (err) { + return null; + } + } + + async postProjectEnvironment(id, body): Promise { + const url = new URL(`/cli/project/${id}/environment`, this._apiOrigin); + return await this._fetch(url, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + } + + async postProjectBuild(id): Promise<{id: string}> { + return await this._fetch<{id: string}>(new URL(`/cli/project/${id}/build`, this._apiOrigin), { + method: "POST" + }); + } + async postProject({ title, slug, @@ -264,10 +293,42 @@ export interface GetProjectResponse { title: string; owner: {id: string; login: string}; creator: {id: string; login: string}; + latestCreatedDeployId: string | null; // Available fields that we don't use // servingRoot: string | null; } +export interface GetProjectEnvironmentResponse { + automatic_builds_enabled: boolean | null; + build_environment_id: string | null; + source: null | { + provider: string; + provider_id: string; + url: string; + branch: string | null; + }; +} + +export interface GetGitHubRepositoriesResponse { + installations: { + id: number; + login: string | null; + name: string | null; + }[]; + repositories: { + provider: "github"; + provider_id: string; + url: string; + default_branch: string; + name: string; + linked_projects: { + title: string; + owner_id: string; + owner_name: string; + }[]; + }[]; +} + export interface DeployInfo { id: string; status: string; diff --git a/src/observableApiConfig.ts b/src/observableApiConfig.ts index 59470c9d6..b87f4e3d9 100644 --- a/src/observableApiConfig.ts +++ b/src/observableApiConfig.ts @@ -36,6 +36,7 @@ export interface DeployConfig { projectId?: string | null; projectSlug: string | null; workspaceLogin: string | null; + continuousDeployment: boolean | null; } export type ApiKey = @@ -87,11 +88,12 @@ export async function getDeployConfig( } } // normalize - let {projectId, projectSlug, workspaceLogin} = config ?? ({} as any); + let {projectId, projectSlug, workspaceLogin, continuousDeployment} = config ?? ({} as any); if (typeof projectId !== "string") projectId = null; if (typeof projectSlug !== "string") projectSlug = null; if (typeof workspaceLogin !== "string") workspaceLogin = null; - return {projectId, projectSlug, workspaceLogin}; + if (typeof continuousDeployment !== "boolean") continuousDeployment = null; + return {projectId, projectSlug, workspaceLogin, continuousDeployment}; } export async function setDeployConfig( diff --git a/test/deploy-test.ts b/test/deploy-test.ts index 7c09a3419..c2f38bdf2 100644 --- a/test/deploy-test.ts +++ b/test/deploy-test.ts @@ -190,8 +190,10 @@ const TEST_OPTIONS: DeployOptions = { const DEPLOY_CONFIG: DeployConfig & {projectId: string; projectSlug: string; workspaceLogin: string} = { projectId: "project123", projectSlug: "bi", - workspaceLogin: "mock-user-ws" + workspaceLogin: "mock-user-ws", + continuousDeployment: false }; +const DEFAULT_ENVIRONMENT = {automatic_builds_enabled: null, build_environment_id: null, source: null}; describe("deploy", () => { before(() => setCurrentDate(new Date("2024-01-10T16:00:00"))); @@ -203,6 +205,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -293,6 +296,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject({...DEPLOY_CONFIG, title: oldTitle}) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -347,6 +351,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(deployConfig) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: deployConfig.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -569,9 +574,8 @@ describe("deploy", () => { const deployId = "deploy456"; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG - }) + .handleGetProject({...DEPLOY_CONFIG}) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId, status: 500}) .start(); const effects = new MockDeployEffects({deployConfig: DEPLOY_CONFIG}); @@ -596,6 +600,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .handlePostDeployManifest({deployId, files: [{deployId, path: "index.html", action: "upload"}]}) .handlePostDeployFile({deployId, status: 500}) @@ -620,6 +625,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId, status: 500}) @@ -728,6 +734,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({ @@ -761,10 +768,8 @@ describe("deploy", () => { const deployId = "deployId"; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG, - projectId: newProjectId - }) + .handleGetProject({...DEPLOY_CONFIG, projectId: newProjectId}) + .handleGetProjectEnvironment({projectId: newProjectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: newProjectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -782,10 +787,8 @@ describe("deploy", () => { const oldDeployConfig = {...DEPLOY_CONFIG, projectId: "oldProjectId"}; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG, - projectId: newProjectId - }) + .handleGetProject({...DEPLOY_CONFIG, projectId: newProjectId}) + .handleGetProjectEnvironment({projectId: newProjectId, environment: DEFAULT_ENVIRONMENT}) .start(); const effects = new MockDeployEffects({deployConfig: oldDeployConfig, isTty: true}); effects.clack.inputs.push(false); // State doesn't match do you want to continue deploying? @@ -808,6 +811,7 @@ describe("deploy", () => { ...DEPLOY_CONFIG, projectId: newProjectId }) + .handleGetProjectEnvironment({projectId: newProjectId, environment: DEFAULT_ENVIRONMENT}) .start(); const effects = new MockDeployEffects({deployConfig: oldDeployConfig, isTty: false, debug: true}); try { @@ -829,6 +833,7 @@ describe("deploy", () => { ...DEPLOY_CONFIG, projectId: newProjectId }) + .handleGetProjectEnvironment({projectId: newProjectId, environment: DEFAULT_ENVIRONMENT}) .start(); const effects = new MockDeployEffects({deployConfig, isTty: true}); effects.clack.inputs.push(false); @@ -849,6 +854,7 @@ describe("deploy", () => { .handlePostAuthRequestPoll("accepted") .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -870,6 +876,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -894,7 +901,11 @@ describe("deploy", () => { force: null, config: {...TEST_OPTIONS.config, output: "test/output/does-not-exist"} } satisfies DeployOptions; - getCurrentObservableApi().handleGetCurrentUser().handleGetProject(DEPLOY_CONFIG).start(); + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) + .start(); const effects = new MockDeployEffects({ deployConfig: DEPLOY_CONFIG, fixedInputStatTime: new Date("2024-03-09"), @@ -909,7 +920,11 @@ describe("deploy", () => { ...TEST_OPTIONS, force: null } satisfies DeployOptions; - getCurrentObservableApi().handleGetCurrentUser().handleGetProject(DEPLOY_CONFIG).start(); + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) + .start(); const effects = new MockDeployEffects({ deployConfig: DEPLOY_CONFIG, fixedInputStatTime: new Date("2024-03-09"), @@ -927,7 +942,11 @@ describe("deploy", () => { ...TEST_OPTIONS, force: null } satisfies DeployOptions; - getCurrentObservableApi().handleGetCurrentUser().handleGetProject(DEPLOY_CONFIG).start(); + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) + .start(); const effects = new MockDeployEffects({ deployConfig: DEPLOY_CONFIG, fixedInputStatTime: new Date("2024-03-11"), @@ -945,7 +964,11 @@ describe("deploy", () => { ...TEST_OPTIONS, force: "build" } satisfies DeployOptions; - getCurrentObservableApi().handleGetCurrentUser().handleGetProject(DEPLOY_CONFIG).start(); + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) + .start(); const effects = new MockDeployEffects({ deployConfig: DEPLOY_CONFIG, fixedInputStatTime: new Date("2024-03-09"), @@ -971,6 +994,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -1002,6 +1026,7 @@ describe("deploy", () => { getCurrentObservableApi() .handleGetCurrentUser() .handleGetProject(DEPLOY_CONFIG) + .handleGetProjectEnvironment({projectId: DEPLOY_CONFIG.projectId, environment: DEFAULT_ENVIRONMENT}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId}) .expectFileUpload({deployId, path: "index.html", action: "upload"}) .expectFileUpload({deployId, path: "_observablehq/client.00000001.js", action: "skip"}) diff --git a/test/mocks/observableApi.ts b/test/mocks/observableApi.ts index 65593636f..d497afdb7 100644 --- a/test/mocks/observableApi.ts +++ b/test/mocks/observableApi.ts @@ -4,6 +4,7 @@ import PendingInterceptorsFormatter from "undici/lib/mock/pending-interceptors-f import type {BuildManifest} from "../../src/build.js"; import type { GetCurrentUserResponse, + GetProjectEnvironmentResponse, GetProjectResponse, PaginatedList, PostAuthRequestPollResponse, @@ -166,7 +167,8 @@ class ObservableApiMock { slug: projectSlug, title, creator: {id: "user-id", login: "user-login"}, - owner: {id: "workspace-id", login: "workspace-login"} + owner: {id: "workspace-id", login: "workspace-login"}, + latestCreatedDeployId: null } satisfies GetProjectResponse) : emptyErrorBody; const headers = authorizationHeader(status !== 401 && status !== 403); @@ -203,7 +205,8 @@ class ObservableApiMock { slug, title: "Mock Project", owner, - creator + creator, + latestCreatedDeployId: null } satisfies GetProjectResponse) : emptyErrorBody; const headers = authorizationHeader(status !== 403); @@ -235,7 +238,8 @@ class ObservableApiMock { creator, owner, title: p.title ?? "Mock Title", - accessLevel: p.accessLevel ?? "private" + accessLevel: p.accessLevel ?? "private", + latestCreatedDeployId: null })) } satisfies PaginatedList) : emptyErrorBody; @@ -263,6 +267,25 @@ class ObservableApiMock { return this; } + handleGetProjectEnvironment({ + projectId, + environment, + status = 200 + }: { + projectId: string; + environment?: GetProjectEnvironmentResponse; + status?: number; + }): ObservableApiMock { + const response = status == 200 ? JSON.stringify(environment) : emptyErrorBody; + const headers = authorizationHeader(status !== 403); + this.addHandler((pool) => + pool + .intercept({path: `/cli/project/${projectId}/environment`, method: "GET", headers: headersMatcher(headers)}) + .reply(status, response, {headers: {"content-type": "application/json"}}) + ); + return this; + } + expectStandardFiles(options: Omit) { return this.expectFileUpload({...options, path: "index.html"}) .expectFileUpload({...options, path: "_observablehq/client.00000001.js"})