diff --git a/tests/integration/core.test.ts b/tests/integration/core.test.ts new file mode 100644 index 0000000..9133751 --- /dev/null +++ b/tests/integration/core.test.ts @@ -0,0 +1,419 @@ +import { exec } from "tinyexec"; +import { beforeAll, describe, expect, it, onTestFinished } from "vitest"; +import { commitFilesFromBase64 } from "../../src/core.ts"; +import { + createRefMutation, + getRepositoryMetadata, +} from "../../src/github/graphql/queries.ts"; +import type { CommitFilesFromBase64Args } from "../../src/interface.ts"; +import { + deleteBranch, + expectBranchDoesNotExist, + expectBranchHasFile, + expectBranchHasTree, + expectBranchNotHaveFile, + expectParentHasOid, + getOid, + getTempBranch, + octokit, + owner, + repo, + waitForGitHubToBeReady, +} from "./utils.ts"; + +// NOTE: These tests create and update actual branches in the repo. Add tests here sparingly and +// ensure the code doesn't affect the active repo branches. + +const BASIC_FILE_CHANGES_PATH = "foo.txt"; +const BASIC_FILE_BUFFER = Buffer.alloc(1024, "Hello, world!"); +const BASIC_FILE_CHANGES_OID = getOid(BASIC_FILE_BUFFER); +const BASIC_FILE_CONTENTS = BASIC_FILE_BUFFER.toString("base64"); +const BASIC_FILE_CHANGES = { + additions: [ + { + path: BASIC_FILE_CHANGES_PATH, + contents: BASIC_FILE_CONTENTS, + }, + ], +}; + +// Match branch name as in core.ts +function getInternalTempBranch(name: string) { + return `changesets-ghcommit-temp/${name}`; +} + +describe("commitFilesFromBase64", () => { + let repositoryId: string; + let testTargetCommit: string; + + /** + * For tests, important that this commit is not an ancestor of TEST_TARGET_COMMIT, + * to ensure that non-fast-forward pushes are tested + */ + let testTargetCommit2: string; + let testTargetTree2: string; + + async function commitFilesFromBase64WithDefaults( + args: Omit< + CommitFilesFromBase64Args, + "octokit" | "owner" | "repo" | "message" | "base" + > & + Partial>, + ) { + return await commitFilesFromBase64({ + octokit, + owner, + repo, + message: "Test commit", + ...args, + // Allow overrides + base: args.base ?? { + commit: testTargetCommit, + }, + }); + } + + beforeAll(async () => { + const response = await getRepositoryMetadata(octokit, { + owner, + repo, + baseRef: "HEAD", + targetRef: "HEAD", + }); + if (!response?.id) { + throw new Error("Repository not found"); + } + repositoryId = response.id; + + // Get recent 2 commits to perform tests on + const logOutput = await exec( + "git", + ["log", "-n", "2", "--pretty=format:%H %T"], + { nodeOptions: { cwd: process.cwd() } }, + ); + const logs = logOutput.stdout + .trim() + .split("\n") + .map((line) => { + const [oid, tree] = line.split(" "); + return { oid, tree }; + }); + + testTargetCommit = logs[1]?.oid ?? "N/A"; + testTargetCommit2 = logs[0]?.oid ?? "N/A"; + testTargetTree2 = logs[0]?.tree ?? "N/A"; + }); + + it("can commit files", async () => { + const branch = getTempBranch("basic-commit"); + onTestFinished(() => deleteBranch(branch)); + + const buffers = { + newFile: Buffer.from("Hello, world!"), + updated: Buffer.from("Hello, world!"), + nested: Buffer.from("Hello, world!"), + }; + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: { + additions: [ + { + path: "new-file.txt", + contents: buffers.newFile.toString("base64"), + }, + { + path: "README.md", + contents: buffers.updated.toString("base64"), + }, + { + path: "tests/file.txt", + contents: buffers.nested.toString("base64"), + }, + ], + deletions: [{ path: "CHANGELOG.md" }], + }, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: "new-file.txt", + fileOid: getOid(buffers.newFile), + }); + await expectBranchHasFile({ + branch, + filePath: "README.md", + fileOid: getOid(buffers.updated), + }); + await expectBranchHasFile({ + branch, + filePath: "tests/file.txt", + fileOid: getOid(buffers.nested), + }); + await expectBranchNotHaveFile({ branch, filePath: "CHANGELOG.md" }); + }); + + it("can commit large file sizes", async () => { + const branch = getTempBranch("file-size"); + onTestFinished(() => deleteBranch(branch)); + + const buffers = { + "1KiB": Buffer.alloc(1024, "Hello, world!"), + "1MiB": Buffer.alloc(1024 * 1024, "Hello, world!"), + "10MiB": Buffer.alloc(1024 * 1024 * 10, "Hello, world!"), + }; + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: { + additions: Object.entries(buffers).map(([sizeName, buffer]) => ({ + path: `${sizeName}.txt`, + contents: buffer.toString("base64"), + })), + }, + }); + + await waitForGitHubToBeReady(); + + for (const [sizeName, buffer] of Object.entries(buffers)) { + await expectBranchHasFile({ + branch, + filePath: `${sizeName}.txt`, + fileOid: getOid(buffer), + }); + } + }); + + it("can commit using branch as a base", async () => { + const branch = getTempBranch("branch-base"); + onTestFinished(() => deleteBranch(branch)); + + await commitFilesFromBase64WithDefaults({ + branch, + base: { + branch: "main", + }, + fileChanges: BASIC_FILE_CHANGES, + }); + + await waitForGitHubToBeReady(); + + // Don't test tree for this one as it will change over time / be unstable + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + }); + + // oxlint-disable-next-line vitest/no-disabled-tests + it.skip("can commit using tag as a base", async () => { + const branch = getTempBranch("tag-base"); + onTestFinished(() => deleteBranch(branch)); + + await commitFilesFromBase64WithDefaults({ + branch, + base: { + // for some reason the tag used here needs to have `.github/workflows` identical~ to the default branch + // otherwise, GitHub rejects `createRef` with "Resource not accessible by integration" and reports missing `workflows=write` permission + tag: "v1.4.0", + }, + fileChanges: BASIC_FILE_CHANGES, + }); + + await waitForGitHubToBeReady(); + + // Don't test tree for this one as it will change over time / be unstable + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + }); + + it("can commit using commit as a base", async () => { + const branch = getTempBranch("commit-base"); + onTestFinished(() => deleteBranch(branch)); + + await commitFilesFromBase64WithDefaults({ + branch, + base: { + commit: testTargetCommit, + }, + fileChanges: BASIC_FILE_CHANGES, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + }); + + describe("existing branches", () => { + it("can commit to existing branch when force is true", async () => { + const branch = getTempBranch("existing-branch-force"); + const internalTempBranch = getInternalTempBranch(branch); + onTestFinished(() => deleteBranch(branch)); + onTestFinished(() => deleteBranch(internalTempBranch, true)); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: testTargetCommit2, + }, + }); + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: BASIC_FILE_CHANGES, + force: true, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + + await expectParentHasOid({ branch, oid: testTargetCommit }); + await expectBranchDoesNotExist(internalTempBranch); + }); + + it("cleans up a pre-existing temporary branch when force is true", async () => { + const branch = getTempBranch("existing-branch-force-existing-temp"); + const internalTempBranch = getInternalTempBranch(branch); + onTestFinished(() => deleteBranch(branch)); + onTestFinished(() => deleteBranch(internalTempBranch, true)); + + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: testTargetCommit2, + }, + }); + + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${internalTempBranch}`, + oid: testTargetCommit2, + }, + }); + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: BASIC_FILE_CHANGES, + force: true, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + + await expectParentHasOid({ branch, oid: testTargetCommit }); + await expectBranchDoesNotExist(internalTempBranch); + }); + + it("cannot commit to existing branch when force is false", async () => { + const branch = getTempBranch("existing-branch-no-force"); + onTestFinished(() => deleteBranch(branch)); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: testTargetCommit2, + }, + }); + + await waitForGitHubToBeReady(); + + await expect(() => + commitFilesFromBase64WithDefaults({ + branch, + fileChanges: BASIC_FILE_CHANGES, + }), + ).rejects.toThrow( + `Branch ${branch} exists already and does not match base`, + ); + + await expectBranchHasTree({ + branch, + treeOid: testTargetTree2, + }); + }); + + it("can commit to existing branch when force is false and target matches base", async () => { + const branch = getTempBranch("existing-branch-matching-base"); + onTestFinished(() => deleteBranch(branch)); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: testTargetCommit, + }, + }); + + await waitForGitHubToBeReady(); + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: BASIC_FILE_CHANGES, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + }); + + it("can commit to same branch as base", async () => { + const branch = getTempBranch("same-branch-as-base"); + onTestFinished(() => deleteBranch(branch)); + + // Create an exiting branch + await createRefMutation(octokit, { + input: { + repositoryId, + name: `refs/heads/${branch}`, + oid: testTargetCommit, + }, + }); + + await waitForGitHubToBeReady(); + + await commitFilesFromBase64WithDefaults({ + branch, + fileChanges: BASIC_FILE_CHANGES, + }); + + await waitForGitHubToBeReady(); + + await expectBranchHasFile({ + branch, + filePath: BASIC_FILE_CHANGES_PATH, + fileOid: BASIC_FILE_CHANGES_OID, + }); + }); + }); +}); diff --git a/tests/integration/env.ts b/tests/integration/env.ts deleted file mode 100644 index 24c25c7..0000000 --- a/tests/integration/env.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { pino } from "pino"; - -export const ROOT_TEST_BRANCH_PREFIX = process.env.ROOT_TEST_BRANCH_PREFIX!; -export const ROOT_TEMP_DIRECTORY = process.env.ROOT_TEMP_DIRECTORY!; - -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -if (!GITHUB_TOKEN) { - throw new Error("GITHUB_TOKEN must be set"); -} - -const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; - -const [owner, repo] = GITHUB_REPOSITORY?.split("/") || []; -if (!owner || !repo) { - throw new Error("GITHUB_REPOSITORY must be set"); -} - -export const ENV = { - GITHUB_TOKEN, -}; - -export const REPO = { owner, repo }; - -export const log = pino({ - level: process.env.RUNNER_DEBUG === "1" ? "debug" : "info", - transport: { - target: "pino-pretty", - }, -}); diff --git a/tests/integration/globalSetup.ts b/tests/integration/globalSetup.ts deleted file mode 100644 index 2c2a5a3..0000000 --- a/tests/integration/globalSetup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import fs from "fs/promises"; - -export async function teardown() { - const directory = process.env.ROOT_TEMP_DIRECTORY; - if (!directory) { - throw new Error("ROOT_TEMP_DIRECTORY must be set"); - } - - console.log(`Deleting directory: ${directory}`); - - await fs.rm(directory, { force: true, recursive: true }).catch((error) => { - console.error(`Error deleting directory: ${error}`); - }); -} diff --git a/tests/integration/node.test.ts b/tests/integration/node.test.ts deleted file mode 100644 index aeaceb4..0000000 --- a/tests/integration/node.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { promises as fs } from "fs"; -import { getOctokit } from "@actions/github"; -import git from "isomorphic-git"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { - createRefMutation, - getRefTreeQuery, - getRepositoryMetadata, -} from "../../src/github/graphql/queries.ts"; -import { commitFilesFromBuffers } from "../../src/node.ts"; -import { ENV, REPO, ROOT_TEST_BRANCH_PREFIX, log } from "./env.ts"; -import { deleteBranches, waitForGitHubToBeReady } from "./util.ts"; - -// TODO: re-enable strict tree tests when GitHub have addressed the createRef -// bug that's currently used in integration tests -// See: https://github.com/orgs/community/discussions/136777 - -const octokit = getOctokit(ENV.GITHUB_TOKEN); - -const TEST_BRANCH_PREFIX = `${ROOT_TEST_BRANCH_PREFIX}-node`; - -// const TEST_TARGET_COMMIT = "fce2760017eab6d85388ed5cfdfac171559d80b3"; -/** - * For tests, important that this commit is not an ancestor of TEST_TARGET_COMMIT, - * to ensure that non-fast-forward pushes are tested - */ -// const TEST_TARGET_COMMIT_2 = "7ba8473f02849de3b5449b25fc83c5245d338d94"; -// const TEST_TARGET_TREE_2 = "95c9ea756f3686614dcdc1c42f7f654b684cdac2"; - -const BASIC_FILE_CHANGES_PATH = "foo.txt"; -const BASIC_FILE_CHANGES_OID = "0e23339619d605319ec4b49a0ac9dd94598eff8e"; -const BASIC_FILE_CONTENTS = { - message: { - headline: "Test commit", - body: "This is a test commit", - }, - fileChanges: { - additions: [ - { - path: BASIC_FILE_CHANGES_PATH, - contents: Buffer.alloc(1024, "Hello, world!"), - }, - ], - }, - log, -}; - -// const TEST_TARGET_TREE_WITH_BASIC_CHANGES = -// "a3431c9b42b71115c52bc6fbf9da3682cf0ed5e8"; - -const getTempBranchName = (branch: string) => - `changesets-ghcommit-temp/${branch}`; - -describe("node", () => { - const branches: string[] = []; - - // Set timeout to 1 minute - vi.setConfig({ testTimeout: 60 * 1000 }); - - let repositoryId: string; - - const expectBranchHasTree = async ({ - branch, - treeOid, - file, - }: { - branch: string; - treeOid?: string; - file?: { - path: string; - oid: string; - }; - }) => { - const ref = ( - await getRefTreeQuery(octokit, { - ...REPO, - ref: `refs/heads/${branch}`, - path: file?.path ?? "package.json", - }) - ).repository?.ref?.target; - - if (!ref) { - throw new Error("Unexpected missing ref"); - } - - if ("tree" in ref) { - if (treeOid) { - expect(ref.tree.oid).toEqual(treeOid); - } - if (file) { - expect(ref.file?.oid).toEqual(file.oid); - } - } else { - throw new Error("Expected ref to have a tree"); - } - }; - - const expectParentHasOid = async ({ - branch, - oid, - }: { - branch: string; - oid: string; - }) => { - const ref = ( - await getRefTreeQuery(octokit, { - ...REPO, - ref: `refs/heads/${branch}`, - path: "package.json", - }) - ).repository?.ref?.target; - - if (!ref || !("parents" in ref)) { - throw new Error("Unexpected result"); - } - - expect(ref.parents.nodes?.[0]?.oid).toEqual(oid); - }; - - const expectBranchDoesNotExist = async (branch: string) => { - await expect( - octokit.rest.git.getRef({ - ...REPO, - ref: `heads/${branch}`, - }), - ).rejects.toMatchObject({ - status: 404, - }); - }; - - let testTargetCommit: string; - /** - * For tests, important that this commit is not an ancestor of TEST_TARGET_COMMIT, - * to ensure that non-fast-forward pushes are tested - */ - let testTargetCommit2: string; - let testTargetTree2: string; - - beforeAll(async () => { - const response = await getRepositoryMetadata(octokit, { - ...REPO, - baseRef: "HEAD", - targetRef: "HEAD", - }); - if (!response?.id) { - throw new Error("Repository not found"); - } - repositoryId = response.id; - - // Get recent 2 commits to perform tests on - const log = await git.log({ fs, dir: process.cwd(), depth: 2 }); - testTargetCommit = log[1]?.oid ?? "N/A"; - testTargetCommit2 = log[0]?.oid ?? "N/A"; - testTargetTree2 = log[0]?.commit.tree ?? "N/A"; - }); - - describe("commitFilesFromBuffers", () => { - describe("can commit single file of various sizes", () => { - const SIZES_BYTES = { - "1KiB": { - sizeBytes: 1024, - treeOid: "547dfe4079b53c3b45a6717ac1ed6d98512f0a1c", - fileOid: "0e23339619d605319ec4b49a0ac9dd94598eff8e", - }, - "1MiB": { - sizeBytes: 1024 * 1024, - treeOid: "a6dca57388cf08de146bcc01a2113b218d6c2858", - fileOid: "a1d7fed1b4a8de1b665dc4f604015b2d87ef978f", - }, - "10MiB": { - sizeBytes: 1024 * 1024 * 10, - treeOid: "c4788256a2c1e3ea4267cff0502a656d992248ec", - fileOid: "e36e74edbb6d3fc181ef584a50f8ee55585d27cc", - }, - }; - - for (const [sizeName, { sizeBytes, fileOid }] of Object.entries( - SIZES_BYTES, - )) { - it(`Can commit a ${sizeName}`, async () => { - const branch = `${TEST_BRANCH_PREFIX}-${sizeName}`; - branches.push(branch); - const contents = Buffer.alloc(sizeBytes, "Hello, world!"); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - message: { - headline: "Test commit", - body: "This is a test commit", - }, - fileChanges: { - additions: [ - { - path: `${sizeName}.txt`, - contents, - }, - ], - }, - log, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - // TODO: re-enable - // treeOid, - file: { - path: `${sizeName}.txt`, - oid: fileOid, - }, - }); - }); - } - }); - - it("can commit using branch as a base", async () => { - const branch = `${TEST_BRANCH_PREFIX}-branch-base`; - branches.push(branch); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - branch: "main", - }, - ...BASIC_FILE_CONTENTS, - }); - - await waitForGitHubToBeReady(); - - // Don't test tree for this one as it will change over time / be unstable - await expectBranchHasTree({ - branch, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - }); - - // oxlint-disable-next-line vitest/no-disabled-tests - it.skip("can commit using tag as a base", async () => { - const branch = `${TEST_BRANCH_PREFIX}-tag-base`; - branches.push(branch); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - // for some reason the tag used here needs to have `.github/workflows` identical~ to the default branch - // otherwise, GitHub rejects `createRef` with "Resource not accessible by integration" and reports missing `workflows=write` permission - tag: "v1.4.0", - }, - ...BASIC_FILE_CONTENTS, - }); - - await waitForGitHubToBeReady(); - - // Don't test tree for this one as it will change over time / be unstable - await expectBranchHasTree({ - branch, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - }); - - it("can commit using commit as a base", async () => { - const branch = `${TEST_BRANCH_PREFIX}-commit-base`; - branches.push(branch); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - ...BASIC_FILE_CONTENTS, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - // TODO: re-enable - // treeOid: TEST_TARGET_TREE_WITH_BASIC_CHANGES, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - }); - - describe("existing branches", () => { - it("can commit to existing branch when force is true", async () => { - const branch = `${TEST_BRANCH_PREFIX}-existing-branch-force`; - branches.push(branch); - - // Create an exiting branch - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: testTargetCommit2, - }, - }); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - ...BASIC_FILE_CONTENTS, - force: true, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - // TODO: re-enable - // treeOid: TEST_TARGET_TREE_WITH_BASIC_CHANGES, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - - await expectParentHasOid({ branch, oid: testTargetCommit }); - await expectBranchDoesNotExist(getTempBranchName(branch)); - }); - - it("cleans up a pre-existing temporary branch when force is true", async () => { - const branch = `${TEST_BRANCH_PREFIX}-existing-branch-force-existing-temp`; - const tempBranch = getTempBranchName(branch); - branches.push(branch, tempBranch); - - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: testTargetCommit2, - }, - }); - - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${tempBranch}`, - oid: testTargetCommit2, - }, - }); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - ...BASIC_FILE_CONTENTS, - force: true, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - - await expectParentHasOid({ branch, oid: testTargetCommit }); - await expectBranchDoesNotExist(tempBranch); - }); - - it("cannot commit to existing branch when force is false", async () => { - const branch = `${TEST_BRANCH_PREFIX}-existing-branch-no-force`; - branches.push(branch); - - // Create an exiting branch - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: testTargetCommit2, - }, - }); - - await waitForGitHubToBeReady(); - - await expect(() => - commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - ...BASIC_FILE_CONTENTS, - }), - ).rejects.toThrow( - `Branch ${branch} exists already and does not match base`, - ); - - await expectBranchHasTree({ - branch, - treeOid: testTargetTree2, - }); - }); - - it("can commit to existing branch when force is false and target matches base", async () => { - const branch = `${TEST_BRANCH_PREFIX}-existing-branch-matching-base`; - branches.push(branch); - - // Create an exiting branch - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: testTargetCommit, - }, - }); - - await waitForGitHubToBeReady(); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - commit: testTargetCommit, - }, - ...BASIC_FILE_CONTENTS, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - // TODO: re-enable - // treeOid: TEST_TARGET_TREE_WITH_BASIC_CHANGES, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - }); - - it("can commit to same branch as base", async () => { - const branch = `${TEST_BRANCH_PREFIX}-same-branch-as-base`; - branches.push(branch); - - // Create an exiting branch - await createRefMutation(octokit, { - input: { - repositoryId, - name: `refs/heads/${branch}`, - oid: testTargetCommit, - }, - }); - - await waitForGitHubToBeReady(); - - await commitFilesFromBuffers({ - octokit, - ...REPO, - branch, - base: { - branch, - }, - ...BASIC_FILE_CONTENTS, - }); - - await waitForGitHubToBeReady(); - - await expectBranchHasTree({ - branch, - // TODO: re-enable - // treeOid: TEST_TARGET_TREE_WITH_BASIC_CHANGES, - file: { - path: BASIC_FILE_CHANGES_PATH, - oid: BASIC_FILE_CHANGES_OID, - }, - }); - }); - }); - }); - - afterAll(async () => { - console.info("Cleaning up test branches"); - - await deleteBranches(octokit, branches); - }); -}); diff --git a/tests/integration/util.ts b/tests/integration/util.ts deleted file mode 100644 index 4b7a52f..0000000 --- a/tests/integration/util.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - deleteRefMutation, - getRepositoryMetadata, - type GitHubClient, -} from "../../src/github/graphql/queries.js"; -import { REPO } from "./env.js"; - -export const deleteBranches = async ( - octokit: GitHubClient, - branches: string[], -) => - Promise.all( - branches.map(async (branch) => { - console.debug(`Deleting branch ${branch}`); - // Get Ref - const ref = await getRepositoryMetadata(octokit, { - ...REPO, - baseRef: `refs/heads/${branch}`, - targetRef: `refs/heads/${branch}`, - }); - - const refId = ref?.baseRef?.id; - - if (!refId) { - console.warn(`Branch ${branch} not found`); - return; - } - - await deleteRefMutation(octokit, { - input: { - refId, - }, - }); - - console.debug(`Deleted branch ${branch}`); - }), - ); - -/** - * GitHub sometimes has a delay between making changes to a git repo, - * and those changes being reflected in the API. - * - * This function is a workaround to wait for GitHub to be ready - * before running these assertions. - * - * It slows down testing a bit, - * but it's better than having flaky tests. - */ -export const waitForGitHubToBeReady = () => - new Promise((r) => setTimeout(r, 5000)); diff --git a/tests/integration/utils.ts b/tests/integration/utils.ts new file mode 100644 index 0000000..bf24f44 --- /dev/null +++ b/tests/integration/utils.ts @@ -0,0 +1,188 @@ +import crypto from "node:crypto"; +import { getOctokit } from "@actions/github"; +import { pino } from "pino"; +import { expect } from "vitest"; +import { + deleteRefMutation, + getRefTreeQuery, + getRepositoryMetadata, +} from "../../src/github/graphql/queries.ts"; + +export const githubToken = process.env.GITHUB_TOKEN!; +export const [owner, repo] = process.env.GITHUB_REPOSITORY!.split("/")!; + +export const octokit = getOctokit(githubToken); + +export const log = pino({ + level: process.env.RUNNER_DEBUG === "1" ? "debug" : "info", + transport: { + target: "pino-pretty", + }, +}); + +/** + * GitHub sometimes has a delay between making changes to a git repo, + * and those changes being reflected in the API. + * + * This function is a workaround to wait for GitHub to be ready + * before running these assertions. + * + * It slows down testing a bit, + * but it's better than having flaky tests. + */ +export async function waitForGitHubToBeReady() { + return await new Promise((r) => setTimeout(r, 5000)); +} + +const runHash = crypto.randomBytes(4).toString("hex"); +const runId = process.env.GITHUB_RUN_ID ?? "local"; +export function getTempBranch(name: string) { + return `changesets-ghcommit-test-${runHash}-id-${runId}/${name}`; +} + +/** + * Calculate the SHA using git blob hash format + */ +export function getOid(contents: Buffer): string { + const header = Buffer.from(`blob ${contents.length}\0`); + return crypto + .createHash("sha1") + .update(header) + .update(contents) + .digest("hex"); +} + +// #region Assertion helpers + +export async function expectBranchHasTree({ + branch, + treeOid, +}: { + branch: string; + treeOid: string; +}) { + const ref = ( + await getRefTreeQuery(octokit, { + owner, + repo, + ref: `refs/heads/${branch}`, + path: "package.json", + }) + ).repository?.ref?.target; + + if (!ref) { + throw new Error("Unexpected missing ref"); + } + + expect(ref.tree.oid).toEqual(treeOid); +} + +export async function expectBranchHasFile({ + branch, + filePath, + fileOid, +}: { + branch: string; + filePath: string; + fileOid: string; +}) { + const ref = ( + await getRefTreeQuery(octokit, { + owner, + repo, + ref: `refs/heads/${branch}`, + path: filePath, + }) + ).repository?.ref?.target; + + if (!ref) { + throw new Error("Unexpected missing ref"); + } + + expect(ref.file?.oid).toEqual(fileOid); +} + +export async function expectBranchNotHaveFile({ + branch, + filePath, +}: { + branch: string; + filePath: string; +}) { + await expect(() => + getRefTreeQuery(octokit, { + owner, + repo, + ref: `refs/heads/${branch}`, + path: filePath, + }), + ).rejects.toThrow("Could not resolve file for path"); +} + +export async function expectParentHasOid({ + branch, + oid, +}: { + branch: string; + oid: string; +}) { + const ref = ( + await getRefTreeQuery(octokit, { + owner, + repo, + ref: `refs/heads/${branch}`, + path: "package.json", + }) + ).repository?.ref?.target; + + if (!ref || !("parents" in ref)) { + throw new Error("Unexpected result"); + } + + expect(ref.parents.nodes?.[0]?.oid).toEqual(oid); +} + +export async function expectBranchDoesNotExist(branch: string) { + await expect( + octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }), + ).rejects.toMatchObject({ + status: 404, + }); +} + +// #endregion + +// #region Octokit helpers + +export async function deleteBranch(branch: string, allowNotExist = false) { + try { + const ref = await getRepositoryMetadata(octokit, { + owner, + repo, + baseRef: `refs/heads/${branch}`, + targetRef: `refs/heads/${branch}`, + }); + + const refId = ref?.baseRef?.id; + if (!refId) { + if (!allowNotExist) { + console.warn(`Branch ${branch} not found`); + } + return; + } + + await deleteRefMutation(octokit, { + input: { + refId, + }, + }); + } catch (error) { + console.error(`Failed to delete branch ${branch}:`, error); + } +} + +// #endregion diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index 2e28e6f..cfb9d77 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -1,6 +1,3 @@ -import { randomBytes } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; import { loadEnvFile } from "node:process"; import { defineConfig } from "vitest/config"; @@ -8,17 +5,18 @@ try { loadEnvFile(); } catch {} -process.env.ROOT_TEST_BRANCH_PREFIX ??= `test-${randomBytes(4).toString("hex")}`; -process.env.ROOT_TEMP_DIRECTORY ??= path.join( - os.tmpdir(), - process.env.ROOT_TEST_BRANCH_PREFIX, -); +if (!process.env.GITHUB_TOKEN) { + throw new Error("GITHUB_TOKEN must be set"); +} +if (!process.env.GITHUB_REPOSITORY) { + throw new Error("GITHUB_REPOSITORY must be set"); +} export default defineConfig({ test: { experimental: { preParse: true }, clearMocks: true, - globalSetup: ["./tests/integration/globalSetup.ts"], - include: ["tests/integration/**/*.test.ts"], + testTimeout: 60_000, + include: ["tests/integration/*.test.ts"], }, });