diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99e0ee6a..8befe677 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ If you want to contribute to this project, check out these steps! 1. Check out existing features to make sure your case is not already covered. Also, try [searching open or closed issues](https://github.com/EndBug/add-and-commit/issues) that may cover the same topic. 2. Either [open a new issue](https://github.com/EndBug/add-and-commit/issues/new/choose) or comment on an existing one to let everyone know what you're working on. 3. Edit the source files to implement your feature or fix. -4. On Node.js 24, build the action (`npm ci && npm run build`) and include any `lib/` changes in your commit. With Husky installed, the pre-commit hook rebuilds and stages `lib/` for you. CI fails if the committed `lib/` does not match a clean rebuild. Then test the action in a test repo. +4. On Node.js 24, build the action (`npm ci && npm run build`) and include any `lib/` changes in your commit. With Husky installed, the pre-commit hook rebuilds and stages `lib/` for you. CI fails if the committed `lib/` does not match a clean rebuild. Run `npm test` (unit + integration tests against the built `lib/`). 5. Update the [action manifest](./action.yml) AND the [README](./README.md) with your changes. 6. [Open a PR](https://github.com/EndBug/add-and-commit/compare). diff --git a/test/integration/action.test.ts b/test/integration/action.test.ts new file mode 100644 index 00000000..b464134e --- /dev/null +++ b/test/integration/action.test.ts @@ -0,0 +1,202 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + createFixture, + type Fixture, + gitLog, + gitRevParse, + listFilesAtHead, + remoteHasRef, + removeFile, + runAction, + writeFile, +} from './helpers'; + +describe('action integration', () => { + let fixture: Fixture | undefined; + + beforeEach(() => { + fixture = createFixture(); + }); + + afterEach(() => { + fixture?.cleanup(); + fixture = undefined; + }); + + it('commits changes with push disabled and sets outputs', () => { + const f = fixture!; + writeFile(f.local, 'changed.txt', 'hello\n'); + + const before = gitRevParse(f.local, 'HEAD'); + const result = runAction(f, { + message: 'Add changed.txt', + push: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + expect(result.outputs.pushed).toBe('false'); + expect(result.outputs.commit_long_sha).toMatch(/^[0-9a-f]{40}$/); + expect(result.outputs.commit_sha).toBe( + result.outputs.commit_long_sha!.slice(0, 7), + ); + + const after = gitRevParse(f.local, 'HEAD'); + expect(after).not.toBe(before); + expect(after).toBe(result.outputs.commit_long_sha); + expect(gitLog(f.local, '%s')).toBe('Add changed.txt'); + expect(gitLog(f.local, '%an <%ae>')).toBe( + 'Integration Tester ', + ); + expect(listFilesAtHead(f.local)).toContain('changed.txt'); + expect(remoteHasRef(f.remote, 'HEAD')).toBe(true); + // Remote still on initial commit — nothing pushed. + expect(gitRevParse(f.remote, 'HEAD')).toBe(before); + }); + + it('does nothing when the working tree is clean', () => { + const f = fixture!; + const before = gitRevParse(f.local, 'HEAD'); + const result = runAction(f, {push: 'false'}); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('false'); + expect(result.outputs.pushed).toBe('false'); + expect(result.outputs.commit_long_sha || undefined).toBeUndefined(); + expect(gitRevParse(f.local, 'HEAD')).toBe(before); + }); + + it('applies custom author and committer', () => { + const f = fixture!; + writeFile(f.local, 'id.txt', 'id\n'); + + const result = runAction(f, { + message: 'Custom identity', + author_name: 'Author Name', + author_email: 'author@example.com', + committer_name: 'Committer Name', + committer_email: 'committer@example.com', + push: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + expect(gitLog(f.local, '%an <%ae>')).toBe( + 'Author Name ', + ); + expect(gitLog(f.local, '%cn <%ce>')).toBe( + 'Committer Name ', + ); + }); + + it('creates a local tag', () => { + const f = fixture!; + writeFile(f.local, 'tagged.txt', 'tag me\n'); + + const result = runAction(f, { + message: 'Tagged commit', + tag: 'v0.0.0-test', + push: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + expect(result.outputs.tagged).toBe('true'); + expect(result.outputs.tag_pushed).toBe('false'); + + const tagSha = gitRevParse(f.local, 'refs/tags/v0.0.0-test'); + expect(tagSha).toBe(result.outputs.commit_long_sha); + }); + + it('pushes the commit to the local bare remote', () => { + const f = fixture!; + writeFile(f.local, 'pushed.txt', 'push me\n'); + const beforeRemote = gitRevParse(f.remote, 'HEAD'); + + const result = runAction(f, { + message: 'Push to bare remote', + push: 'true', + fetch: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + expect(result.outputs.pushed).toBe('true'); + + const localHead = gitRevParse(f.local, 'HEAD'); + const remoteHead = gitRevParse(f.remote, 'HEAD'); + expect(remoteHead).toBe(localHead); + expect(remoteHead).not.toBe(beforeRemote); + expect(remoteHead).toBe(result.outputs.commit_long_sha); + }); + + it('creates a new branch and pushes it to the remote', () => { + const f = fixture!; + writeFile(f.local, 'branch.txt', 'branch me\n'); + + const result = runAction(f, { + message: 'Commit on new branch', + new_branch: 'integration-new-branch', + push: 'true', + fetch: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + expect(result.outputs.pushed).toBe('true'); + + expect(remoteHasRef(f.remote, 'refs/heads/integration-new-branch')).toBe( + true, + ); + const remoteBranchSha = gitRevParse( + f.remote, + 'refs/heads/integration-new-branch', + ); + expect(remoteBranchSha).toBe(result.outputs.commit_long_sha); + }); + + it('removes files with the remove input', () => { + const f = fixture!; + // Seed already has README.md; ensure it exists then remove via the action. + expect(listFilesAtHead(f.local)).toContain('README.md'); + // Make a dirty tree so add+remove both run: touch another file and remove README. + writeFile(f.local, 'keep.txt', 'keep\n'); + removeFile(f.local, 'README.md'); + + const result = runAction(f, { + message: 'Remove README', + add: 'keep.txt', + remove: 'README.md', + push: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + + const files = listFilesAtHead(f.local); + expect(files).not.toContain('README.md'); + expect(files).toContain('keep.txt'); + }); + + it('only commits files matched by selective add', () => { + const f = fixture!; + writeFile(f.local, 'include-me.txt', 'yes\n'); + writeFile(f.local, 'skip-me.txt', 'no\n'); + + const result = runAction(f, { + message: 'Selective add', + add: 'include-me.txt', + push: 'false', + }); + + expect(result.status).toBe(0); + expect(result.outputs.committed).toBe('true'); + + const files = listFilesAtHead(f.local); + expect(files).toContain('include-me.txt'); + expect(files).not.toContain('skip-me.txt'); + // Untracked file should still be on disk. + expect(fs.existsSync(path.join(f.local, 'skip-me.txt'))).toBe(true); + }); +}); diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts new file mode 100644 index 00000000..a86dc429 --- /dev/null +++ b/test/integration/helpers.ts @@ -0,0 +1,287 @@ +import {spawnSync, execFileSync} from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const REPO_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const ACTION_ENTRY = path.join(REPO_ROOT, 'lib', 'index.js'); + +export interface Fixture { + /** Absolute path to the bare remote repository. */ + remote: string; + /** Absolute path to the local clone under test. */ + local: string; + /** Default branch name used by the fixture. */ + defaultBranch: string; + /** Remove the fixture root directory. */ + cleanup: () => void; +} + +export interface RunActionResult { + status: number | null; + stdout: string; + stderr: string; + outputs: Record; +} + +function git( + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return execFileSync('git', args, { + cwd, + env, + encoding: 'utf8', + }).trim(); +} + +/** Local-only identity + disable signing so fixtures work when the host has gpgsign. */ +function configureFixtureRepo(repo: string, name: string, email: string) { + git(['config', 'user.name', name], repo); + git(['config', 'user.email', email], repo); + git(['config', 'commit.gpgsign', 'false'], repo); + git(['config', 'tag.gpgsign', 'false'], repo); +} + +/** + * Create an isolated bare remote + local clone under os.tmpdir(). + * origin always points at the local bare path (never a network URL). + */ +export function createFixture(): Fixture { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aac-int-')); + const remote = path.join(root, 'remote.git'); + const seed = path.join(root, 'seed'); + const local = path.join(root, 'local'); + + fs.mkdirSync(remote); + // Pin branch name in the fixture; do not read host init.defaultBranch + // (unset on GitHub-hosted runners → git config --get exits 1). + const defaultBranch = 'main'; + git(['init', '--bare', '-b', defaultBranch], remote); + + git(['clone', '-q', remote, seed], root); + // Empty bare clone may leave HEAD detached; create the seed branch explicitly. + try { + git(['checkout', '-b', defaultBranch], seed); + } catch { + // Already on defaultBranch. + } + configureFixtureRepo(seed, 'Fixture Seed', 'seed@example.com'); + + fs.writeFileSync(path.join(seed, 'README.md'), '# fixture\n'); + git(['add', 'README.md'], seed); + git(['commit', '-q', '-m', 'Initial commit'], seed); + git(['push', '-q', '-u', 'origin', 'HEAD'], seed); + + git(['clone', '-q', remote, local], root); + configureFixtureRepo(local, 'Fixture Local', 'local@example.com'); + + const originUrl = git(['remote', 'get-url', 'origin'], local); + assertLocalOrigin(originUrl, remote); + + return { + remote, + local, + defaultBranch, + cleanup: () => { + fs.rmSync(root, {recursive: true, force: true}); + }, + }; +} + +/** Reject network / GitHub remotes — push tests must stay on the local bare path. */ +export function assertLocalOrigin(originUrl: string, expectedRemote: string) { + const normalized = originUrl.replace(/\/$/, ''); + const expected = expectedRemote.replace(/\/$/, ''); + if (normalized !== expected) { + throw new Error( + 'Fixture origin must be the local bare path.\n' + + ` expected: ${expected}\n` + + ` actual: ${originUrl}`, + ); + } + if (/^https?:\/\//i.test(originUrl) || /github\.com/i.test(originUrl)) { + throw new Error(`Refusing non-local origin URL: ${originUrl}`); + } +} + +function parseGitHubOutput(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + const content = fs.readFileSync(filePath, 'utf8'); + const outputs: Record = {}; + let i = 0; + const lines = content.split('\n'); + + while (i < lines.length) { + const line = lines[i]; + if (!line) { + i++; + continue; + } + const heredoc = line.match(/^([^=]+)<<(.+)$/); + if (heredoc) { + const [, name, delimiter] = heredoc; + const valueLines: string[] = []; + i++; + while (i < lines.length && lines[i] !== delimiter) { + valueLines.push(lines[i]); + i++; + } + outputs[name] = valueLines.join('\n'); + i++; // skip delimiter + continue; + } + const eq = line.indexOf('='); + if (eq !== -1) { + outputs[line.slice(0, eq)] = line.slice(eq + 1); + } + i++; + } + return outputs; +} + +export interface ActionInputs { + add?: string; + author_name?: string; + author_email?: string; + commit?: string; + committer_name?: string; + committer_email?: string; + cwd?: string; + default_author?: string; + fetch?: string; + message?: string; + new_branch?: string; + pathspec_error_handling?: string; + pull?: string; + push?: string; + remove?: string; + tag?: string; + tag_push?: string; +} + +/** + * Spawn the shipped action (lib/index.js) with an allowlisted environment. + * + * The action resolves the working tree as `path.join(process.cwd(), cwdInput)`. + * On modern Node, `path.join` does not treat an absolute second segment as a + * new root, so we spawn with `process.cwd()` set to the fixture and pass + * `cwd: '.'` rather than an absolute path. + */ +export function runAction( + fixture: Fixture, + inputs: ActionInputs = {}, +): RunActionResult { + assertLocalOrigin( + git(['remote', 'get-url', 'origin'], fixture.local), + fixture.remote, + ); + + if (!fs.existsSync(ACTION_ENTRY)) { + throw new Error( + `Missing ${ACTION_ENTRY}. Build the action (npm run build) before running integration tests.`, + ); + } + + const outputFile = path.join( + path.dirname(fixture.local), + `github_output_${process.pid}_${Date.now()}`, + ); + fs.writeFileSync(outputFile, ''); + + const restInputs = {...inputs}; + delete restInputs.cwd; + const merged: Record = { + // Mirror action.yml defaults that matter when spawning lib/ directly. + cwd: '.', + add: '.', + default_author: 'github_actor', + pathspec_error_handling: 'ignore', + push: 'false', + fetch: 'false', + author_name: 'Integration Tester', + author_email: 'integration@example.com', + message: 'Integration test commit', + ...restInputs, + }; + + const env: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR ?? os.tmpdir(), + LANG: process.env.LANG, + GITHUB_ACTOR: 'integration-tester', + GITHUB_WORKFLOW: 'integration-test', + GITHUB_OUTPUT: outputFile, + // Avoid picking up a real event payload if present in the parent env. + GITHUB_EVENT_PATH: '', + GITHUB_EVENT_NAME: 'push', + GITHUB_REF: `refs/heads/${fixture.defaultBranch}`, + }; + + for (const [key, value] of Object.entries(merged)) { + if (value !== undefined) { + env[`INPUT_${key.toUpperCase()}`] = value; + } + } + + const result = spawnSync(process.execPath, [ACTION_ENTRY], { + // Workspace = fixture clone; action cwd input stays relative ('.'). + cwd: fixture.local, + env, + encoding: 'utf8', + // Action can take a bit when doing push/tag; keep a generous limit. + timeout: 60_000, + }); + + const outputs = parseGitHubOutput(outputFile); + try { + fs.unlinkSync(outputFile); + } catch { + // ignore + } + + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + outputs, + }; +} + +export function gitLog(repo: string, format: string, ref = 'HEAD'): string { + return git(['log', '-1', `--format=${format}`, ref], repo); +} + +export function gitRevParse(repo: string, ref: string): string { + return git(['rev-parse', ref], repo); +} + +export function remoteHasRef(remote: string, ref: string): boolean { + try { + git(['rev-parse', '--verify', ref], remote); + return true; + } catch { + return false; + } +} + +export function listFilesAtHead(repo: string): string[] { + const out = git(['ls-tree', '-r', '--name-only', 'HEAD'], repo); + return out ? out.split('\n') : []; +} + +export function writeFile(repo: string, relativePath: string, content: string) { + const full = path.join(repo, relativePath); + fs.mkdirSync(path.dirname(full), {recursive: true}); + fs.writeFileSync(full, content); +} + +export function removeFile(repo: string, relativePath: string) { + fs.unlinkSync(path.join(repo, relativePath)); +}