Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/cli/e2e/checkout.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createProject,
deleteProject,
runCli,
uniqueProjectName,
} from "./helpers.js";

/**
* `neon checkout --agent` pins a branch and then pulls its env. The unit suites cover the
* response shapes against mocks, but with `--no-env-pull` — so the one path they cannot reach
* is the one that matters most: a real pull that resolves a connection string and lands it on
* disk, and the `checked_out`/`written` JSON the command assembles from that outcome.
*
* This is the regression that guards it. It is the analogue of the `pull_env` step in
* `init.e2e.test.ts`, scoped to the `checkout --agent` response contract: the field the agent
* reads (`env_pull`, `env_file`, `pulled`) and the file those fields describe must agree.
*/
describe.sequential("e2e — neon checkout --agent pulls env for real", () => {
let projectId: string;

/**
* `env pull` writes a connection string and password here, so the whole directory is
* throwaway and removed in teardown, credentials included.
*/
const workdir = mkdtempSync(join(tmpdir(), "neon-checkout-e2e-"));
const contextFile = join(workdir, ".neon");

beforeAll(async () => {
projectId = await createProject({ name: uniqueProjectName("cli-co") });
});

afterAll(async () => {
rmSync(workdir, { recursive: true, force: true });
if (projectId) await deleteProject(projectId);
});

it("pins the branch and reports the connection string it wrote to disk", async () => {
const result = await runCli(
["checkout", "main", "--agent", "--project-id", projectId],
{ cwd: workdir, contextFile },
);
expect(result.code, result.stderr).toBe(0);

const response = JSON.parse(result.stdout) as {
status: string;
env_pull: string;
env_file?: string;
pulled?: string[];
context: { projectId: string; branch: string };
};

// The branch is pinned and env actually landed: `written`, not `empty`/`skipped`.
expect(response.status).toBe("checked_out");
expect(response.env_pull).toBe("written");
expect(response.context.projectId).toBe(projectId);
expect(response.context.branch).toBe("main");

// The fields the agent reads must name what was written.
expect(response.env_file).toMatch(/\.env\.local$/);
expect(response.pulled).toContain("DATABASE_URL");

// Exit 0 and a `written` status are not enough on their own — the whole point is a
// connection string on disk, so read the file the response points at and prove it.
const envFile = join(workdir, ".env.local");
expect(existsSync(envFile)).toBe(true);
expect(readFileSync(envFile, "utf8")).toMatch(
/^DATABASE_URL="?postgresql:\/\/.+/m,
);
});

it("still reports checked_out when the env pull fails — the pin stands", async () => {
// The design point that motivated this whole change: pinning a branch and
// pulling its env are separate outcomes. A failed pull must NOT be reported
// as a top-level failure, because the branch really is pinned — the agent
// just has to notice `env_pull: "failed"` and re-run `env pull`.
//
// Force the pull to fail deterministically without depending on API flakiness:
// pre-create `.env.local` as a *directory*, so the pull throws EISDIR reading
// it back — after the pin has already written `.neon`.
const failWorkdir = mkdtempSync(
join(tmpdir(), "neon-checkout-e2e-fail-"),
);
mkdirSync(join(failWorkdir, ".env.local"));
const failContext = join(failWorkdir, ".neon");

const result = await runCli(
["checkout", "main", "--agent", "--project-id", projectId],
{ cwd: failWorkdir, contextFile: failContext },
);

try {
// Exit 0: a failed pull is a soft failure. The pin succeeded, so the
// command does not fail the process — the outcome is carried in the JSON
// (`env_pull: "failed"`), which is the field an agent must branch on.
expect(result.code, result.stderr).toBe(0);
const response = JSON.parse(result.stdout) as {
status: string;
env_pull: string;
context: { projectId: string; branch: string };
};
// The branch was still pinned: checked_out, with env_pull flagging that
// DATABASE_URL never landed on disk.
expect(response.status).toBe("checked_out");
expect(response.env_pull).toBe("failed");
expect(response.context.projectId).toBe(projectId);
expect(response.context.branch).toBe("main");
// The pin is the durable half — it's in `.neon` regardless of the pull.
expect(JSON.parse(readFileSync(failContext, "utf8")).branch).toBe(
"main",
);
} finally {
rmSync(failWorkdir, { recursive: true, force: true });
}
});
});
13 changes: 13 additions & 0 deletions packages/cli/mocks/main/projects/proj-in-org/branches/GET.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"branches": [
{
"id": "br-in-org-branch-123456",
"project_id": "proj-in-org",
"name": "main",
"default": true,
"current_state": "ready",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
155 changes: 155 additions & 0 deletions packages/cli/src/commands/__snapshots__/checkout.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,160 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`checkout > --agent mode > --agent emits parseable JSON on stdout with the invoked binary name in the template 1`] = `
"{
"status": "needs_branch",
"instruction": "Ask the user which branch to check out, then re-run the next_command_template with the chosen branch name.",
"options": [
{
"id": "br-main-branch-123456",
"name": "main",
"default": true
},
{
"id": "br-sunny-branch-123456",
"name": "test_branch",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "123",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_fixed_cu",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_autoscaling",
"default": false
},
{
"id": "br-protected-branch-123456",
"name": "protected_branch",
"default": false
}
],
"context": {
"projectId": "test"
},
"next_command_template": "neon checkout <branch> --agent --project-id test"
}
"
`;

exports[`checkout > --agent mode > a branch name pins it and emits checked_out JSON 1`] = `
"{
"status": "checked_out",
"context_file": "<TMP>/agent_checked_out/.neon",
"context": {
"projectId": "test",
"branch": "main"
},
"env_pull": "skipped",
"message": "Checked out main. Env pull was skipped (--no-env-pull); no vars written."
}
"
`;

exports[`checkout > --agent mode > a nonexistent branch emits needs_branch (never silently creates) 1`] = `
"{
"status": "needs_branch",
"instruction": "Branch \\"no-such-branch\\" was not found in this project. Ask the user which existing branch to check out, then re-run the next_command_template with it. This never creates a branch.",
"options": [
{
"id": "br-main-branch-123456",
"name": "main",
"default": true
},
{
"id": "br-sunny-branch-123456",
"name": "test_branch",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "123",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_fixed_cu",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_autoscaling",
"default": false
},
{
"id": "br-protected-branch-123456",
"name": "protected_branch",
"default": false
}
],
"context": {
"projectId": "test"
},
"next_command_template": "neon checkout <branch> --agent --project-id test"
}
"
`;

exports[`checkout > --agent mode > no branch arg on a multi-branch project emits needs_branch JSON 1`] = `
"{
"status": "needs_branch",
"instruction": "Ask the user which branch to check out, then re-run the next_command_template with the chosen branch name.",
"options": [
{
"id": "br-main-branch-123456",
"name": "main",
"default": true
},
{
"id": "br-sunny-branch-123456",
"name": "test_branch",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "123",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_fixed_cu",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_autoscaling",
"default": false
},
{
"id": "br-protected-branch-123456",
"name": "protected_branch",
"default": false
}
],
"context": {
"projectId": "test"
},
"next_command_template": "neon checkout <branch> --agent --project-id test"
}
"
`;

exports[`checkout > --agent mode > with no resolvable project emits an error JSON, never a prompt 1`] = `
"{
"status": "error",
"code": "INTERNAL_ERROR",
"message": "Could not determine which Neon project to check out a branch from. Provide one via the --project-id flag or a .neon file (created by \`neon link\` / \`neon set-context\`)."
}
"
`;

exports[`checkout > announces the branch currently pinned before switching to a new one 1`] = `""`;

exports[`checkout > auto-detects the project when the API key maps to a single project 1`] = `""`;
Expand Down
65 changes: 49 additions & 16 deletions packages/cli/src/commands/__snapshots__/link.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -76,46 +76,79 @@ exports[`link > --agent mode > with only --org-id emits needs_project JSON 1`] =
"
`;

exports[`link > --agent mode > with only --project-id infers the org and emits linked JSON 1`] = `
exports[`link > --agent mode > with only --project-id infers the org and emits needs_branch JSON 1`] = `
"{
"status": "linked",
"context_file": "<TMP>/agent_linked_infer/.neon",
"status": "needs_branch",
"instruction": "Linked, but no branch is pinned and no env is written yet. Ask the user which branch to check out (or accept the default), then run the next_command_template to pin it and pull its env vars.",
"options": [
{
"id": "br-in-org-branch-123456",
"name": "main",
"default": true
}
],
"context": {
"orgId": "org-7",
"projectId": "proj-in-org"
},
"project": {
"id": "proj-in-org"
},
"message": "Linked <TMP>/agent_linked_infer/.neon to project proj-in-org (org org-7). No branch pinned — run \`neon checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars."
"next_command_template": "neon checkout <branch> --agent --project-id proj-in-org"
}
"
`;

exports[`link > --agent mode > with only --project-id infers the org and emits linked JSON 2`] = `
exports[`link > --agent mode > with only --project-id infers the org and emits needs_branch JSON 2`] = `
"{
"orgId": "org-7",
"projectId": "proj-in-org"
}"
`;

exports[`link > --agent mode > with org+project emits linked JSON (no branch) and writes .neon 1`] = `
exports[`link > --agent mode > with org+project (no branch) emits needs_branch JSON and writes .neon 1`] = `
"{
"status": "linked",
"context_file": "<TMP>/agent_linked_existing/.neon",
"status": "needs_branch",
"instruction": "Linked, but no branch is pinned and no env is written yet. Ask the user which branch to check out (or accept the default), then run the next_command_template to pin it and pull its env vars.",
"options": [
{
"id": "br-main-branch-123456",
"name": "main",
"default": true
},
{
"id": "br-sunny-branch-123456",
"name": "test_branch",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "123",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_fixed_cu",
"default": false
},
{
"id": "br-numbered-branch-123456",
"name": "test_branch_with_autoscaling",
"default": false
},
{
"id": "br-protected-branch-123456",
"name": "protected_branch",
"default": false
}
],
"context": {
"orgId": "org-2",
"projectId": "test"
},
"project": {
"id": "test"
},
"message": "Linked <TMP>/agent_linked_existing/.neon to project test (org org-2). No branch pinned — run \`neon checkout <branch>\` (omit the branch to list options) to pin one and pull its env vars."
"next_command_template": "neon checkout <branch> --agent --project-id test"
}
"
`;

exports[`link > --agent mode > with org+project emits linked JSON (no branch) and writes .neon 2`] = `
exports[`link > --agent mode > with org+project (no branch) emits needs_branch JSON and writes .neon 2`] = `
"{
"orgId": "org-2",
"projectId": "test"
Expand Down
Loading
Loading