diff --git a/packages/rstack/tests/setup/directories.test.ts b/packages/rstack/tests/setup/directories.test.ts new file mode 100644 index 0000000..0363d22 --- /dev/null +++ b/packages/rstack/tests/setup/directories.test.ts @@ -0,0 +1,65 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { installHooks } from '../../src/setup/install.ts'; +import { hooksPath, runGit, runHook, withRepository, writeHook } from './helpers.ts'; + +test('installs a custom hooks directory from the Git root and runs its hook', () => { + withRepository((cwd) => { + const customHooksDir = 'frontend/custom hooks'; + const customHooksPath = `${customHooksDir}/_`; + writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", customHooksDir); + + expect(installHooks({ cwd, hooksDir: customHooksDir })).toEqual({ + status: 'installed', + hooksPath: customHooksPath, + }); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(customHooksPath); + expect(existsSync(path.join(cwd, customHooksPath, 'runner'))).toBe(true); + + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(cwd, 'custom-hook-ran'), 'utf8')).toBe('ran\n'); + }); +}); + +test('installs the default hooks directory from a nested project', () => { + withRepository((cwd) => { + const projectDirectory = path.join(cwd, 'frontend'); + const nestedHooksPath = `frontend/${hooksPath}`; + mkdirSync(projectDirectory); + writeHook( + projectDirectory, + `printf 'root\\n' > nested-hook-cwd +cd frontend +printf 'nested\\n' > nested-hook-ran +`, + ); + + expect(installHooks({ cwd: projectDirectory })).toEqual({ + status: 'installed', + hooksPath: nestedHooksPath, + }); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(nestedHooksPath); + expect(existsSync(path.join(projectDirectory, hooksPath, 'runner'))).toBe(true); + + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(cwd, 'nested-hook-cwd'), 'utf8')).toBe('root\n'); + expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('nested\n'); + }); +}); + +test('installs a custom hooks directory from a nested project', () => { + withRepository((cwd) => { + const projectDirectory = path.join(cwd, 'frontend app'); + mkdirSync(projectDirectory); + + expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({ + status: 'installed', + hooksPath: 'frontend app/config/hooks/_', + }); + expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe( + 'frontend app/config/hooks/_', + ); + expect(existsSync(path.join(projectDirectory, 'config', 'hooks', '_', 'runner'))).toBe(true); + }); +}); diff --git a/packages/rstack/tests/setup/helpers.ts b/packages/rstack/tests/setup/helpers.ts new file mode 100644 index 0000000..141df8e --- /dev/null +++ b/packages/rstack/tests/setup/helpers.ts @@ -0,0 +1,83 @@ +import { type SpawnSyncReturns, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const hooksDir = '.rstack/hooks'; + +export const hooksPath: string = `${hooksDir}/_`; + +export const git = ( + cwd: string, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): SpawnSyncReturns => spawnSync('git', args, { cwd, encoding: 'utf8', env }); + +export const runGit = (cwd: string, args: string[]): string => { + const result = git(cwd, args); + if (result.status !== 0) { + throw new Error(result.stderr || `Git exited with status ${result.status}`); + } + return result.stdout.trim(); +}; + +export const withDirectory = (callback: (cwd: string) => void): void => { + const cwd = mkdtempSync(path.join(tmpdir(), 'rstack hooks ')); + try { + callback(cwd); + } finally { + rmSync(cwd, { force: true, recursive: true }); + } +}; + +export const restoreEnv = (name: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}; + +const hookEnv = (cwd: string, value?: string): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = { + ...process.env, + XDG_CONFIG_HOME: path.join(cwd, '.git', 'xdg'), + }; + + if (value !== undefined) { + env.RSTACK_HOOKS = value; + } + + return env; +}; + +export const writeHook = (cwd: string, content: string, directory: string = hooksDir): void => { + const filePath = path.join(cwd, directory, 'pre-commit'); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); +}; + +export const writeInit = (cwd: string, content: string): void => { + const filePath = path.join(cwd, '.git', 'xdg', 'rstack', 'hooks-init.sh'); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); +}; + +export const runHook = (cwd: string, value?: string): SpawnSyncReturns => + git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value)); + +export const withRepository = (callback: (cwd: string) => void): void => + withDirectory((cwd) => { + const globalConfig = process.env.GIT_CONFIG_GLOBAL; + const noSystemConfig = process.env.GIT_CONFIG_NOSYSTEM; + process.env.GIT_CONFIG_GLOBAL = path.join(cwd, 'global.gitconfig'); + process.env.GIT_CONFIG_NOSYSTEM = '1'; + + try { + runGit(cwd, ['init', '--quiet']); + callback(cwd); + } finally { + restoreEnv('GIT_CONFIG_GLOBAL', globalConfig); + restoreEnv('GIT_CONFIG_NOSYSTEM', noSystemConfig); + } + }); diff --git a/packages/rstack/tests/setup/install.test.ts b/packages/rstack/tests/setup/install.test.ts index 624ea42..643000b 100644 --- a/packages/rstack/tests/setup/install.test.ts +++ b/packages/rstack/tests/setup/install.test.ts @@ -1,94 +1,9 @@ -import { spawnSync } from 'node:child_process'; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; import { createHookFiles } from '../../src/setup/hooks.ts'; import { installHooks } from '../../src/setup/install.ts'; - -const hooksDir = '.rstack/hooks'; -const hooksPath = `${hooksDir}/_`; - -const git = (cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env) => - spawnSync('git', args, { cwd, encoding: 'utf8', env }); - -const runGit = (cwd: string, args: string[]): string => { - const result = git(cwd, args); - if (result.status !== 0) { - throw new Error(result.stderr || `Git exited with status ${result.status}`); - } - return result.stdout.trim(); -}; - -const withDirectory = (callback: (cwd: string) => void): void => { - const cwd = mkdtempSync(path.join(tmpdir(), 'rstack hooks ')); - try { - callback(cwd); - } finally { - rmSync(cwd, { force: true, recursive: true }); - } -}; - -const restoreEnv = (name: string, value: string | undefined): void => { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -}; - -const hookEnv = (cwd: string, value?: string): NodeJS.ProcessEnv => { - const env: NodeJS.ProcessEnv = { - ...process.env, - XDG_CONFIG_HOME: path.join(cwd, '.git', 'xdg'), - }; - - if (value !== undefined) { - env.RSTACK_HOOKS = value; - } - - return env; -}; - -const writeHook = (cwd: string, content: string, directory = hooksDir): void => { - const filePath = path.join(cwd, directory, 'pre-commit'); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, content); -}; - -const writeInit = (cwd: string, content: string): void => { - const filePath = path.join(cwd, '.git', 'xdg', 'rstack', 'hooks-init.sh'); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, content); -}; - -const runHook = (cwd: string, value?: string) => - git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value)); - -const withRepository = (callback: (cwd: string) => void): void => - withDirectory((cwd) => { - const globalConfig = process.env.GIT_CONFIG_GLOBAL; - const noSystemConfig = process.env.GIT_CONFIG_NOSYSTEM; - process.env.GIT_CONFIG_GLOBAL = path.join(cwd, 'global.gitconfig'); - process.env.GIT_CONFIG_NOSYSTEM = '1'; - - try { - runGit(cwd, ['init', '--quiet']); - callback(cwd); - } finally { - restoreEnv('GIT_CONFIG_GLOBAL', globalConfig); - restoreEnv('GIT_CONFIG_NOSYSTEM', noSystemConfig); - } - }); +import { git, hooksPath, restoreEnv, runGit, withDirectory, withRepository } from './helpers.ts'; test('installs generated hooks and configures the repository', () => { withRepository((cwd) => { @@ -109,66 +24,6 @@ test('installs generated hooks and configures the repository', () => { }); }); -test('installs a custom hooks directory from the Git root and runs its hook', () => { - withRepository((cwd) => { - const customHooksDir = 'frontend/custom hooks'; - const customHooksPath = `${customHooksDir}/_`; - writeHook(cwd, "printf 'ran\\n' > custom-hook-ran\n", customHooksDir); - - expect(installHooks({ cwd, hooksDir: customHooksDir })).toEqual({ - status: 'installed', - hooksPath: customHooksPath, - }); - expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(customHooksPath); - expect(existsSync(path.join(cwd, customHooksPath, 'runner'))).toBe(true); - - expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(cwd, 'custom-hook-ran'), 'utf8')).toBe('ran\n'); - }); -}); - -test('installs the default hooks directory from a nested project', () => { - withRepository((cwd) => { - const projectDirectory = path.join(cwd, 'frontend'); - const nestedHooksPath = `frontend/${hooksPath}`; - mkdirSync(projectDirectory); - writeHook( - projectDirectory, - `printf 'root\\n' > nested-hook-cwd -cd frontend -printf 'nested\\n' > nested-hook-ran -`, - ); - - expect(installHooks({ cwd: projectDirectory })).toEqual({ - status: 'installed', - hooksPath: nestedHooksPath, - }); - expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(nestedHooksPath); - expect(existsSync(path.join(projectDirectory, hooksPath, 'runner'))).toBe(true); - - expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(cwd, 'nested-hook-cwd'), 'utf8')).toBe('root\n'); - expect(readFileSync(path.join(projectDirectory, 'nested-hook-ran'), 'utf8')).toBe('nested\n'); - }); -}); - -test('installs a custom hooks directory from a nested project', () => { - withRepository((cwd) => { - const projectDirectory = path.join(cwd, 'frontend app'); - mkdirSync(projectDirectory); - - expect(installHooks({ cwd: projectDirectory, hooksDir: 'config\\hooks' })).toEqual({ - status: 'installed', - hooksPath: 'frontend app/config/hooks/_', - }); - expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe( - 'frontend app/config/hooks/_', - ); - expect(existsSync(path.join(projectDirectory, 'config', 'hooks', '_', 'runner'))).toBe(true); - }); -}); - test('is idempotent and preserves user hooks', () => { withRepository((cwd) => { const userDirectory = path.join(cwd, '.rstack', 'hooks'); @@ -245,76 +100,3 @@ test('reports Git configuration failures without changing hooksPath', () => { expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); }); }); - -test('loads user init and project binaries', () => { - withRepository((cwd) => { - const binDirectory = path.join(cwd, 'node_modules', '.bin'); - mkdirSync(binDirectory, { recursive: true }); - writeInit(cwd, 'set -u\nexport RSTACK_INIT=loaded\n'); - - const command = path.join(binDirectory, 'rstack-hook-command'); - writeFileSync( - command, - `#!/usr/bin/env sh -printf 'ran\\n' > project-bin-ran -`, - ); - chmodSync(command, 0o755); - - writeHook( - cwd, - `printf '%s\\n' "$RSTACK_INIT" > init-ran -rstack-hook-command -`, - ); - - expect(installHooks({ cwd }).status).toBe('installed'); - - expect(runHook(cwd).status).toBe(0); - expect(readFileSync(path.join(cwd, 'init-ran'), 'utf8')).toBe('loaded\n'); - expect(readFileSync(path.join(cwd, 'project-bin-ran'), 'utf8')).toBe('ran\n'); - }); -}); - -test('skips user hooks when disabled by the environment or init', () => { - withRepository((cwd) => { - writeHook(cwd, 'echo ran >> hook-ran\n'); - expect(installHooks({ cwd }).status).toBe('installed'); - - expect(runHook(cwd, '0').status).toBe(0); - expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false); - - writeInit(cwd, 'set -u\nexport RSTACK_HOOKS=0\n'); - - expect(runHook(cwd).status).toBe(0); - expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false); - }); -}); - -test('traces and reports hook failures and command lookup errors', () => { - withRepository((cwd) => { - writeHook(cwd, 'exit 23\n'); - expect(installHooks({ cwd }).status).toBe('installed'); - - const failed = runHook(cwd, '2'); - expect(failed.status).toBe(23); - expect(failed.stderr).toContain('+ sh -e'); - expect(`${failed.stdout}${failed.stderr}`).toContain( - 'Rstack - pre-commit hook failed (code 23)', - ); - - writeHook( - cwd, - `printf '%s\\n' "$PATH" > hook-path -missing-command -`, - ); - const missing = runHook(cwd); - const actualPath = readFileSync(path.join(cwd, 'hook-path'), 'utf8').trim(); - const output = `${missing.stdout}${missing.stderr}`; - - expect(missing.status).toBe(127); - expect(output).toContain('Rstack - pre-commit hook failed (code 127)'); - expect(output).toContain(`Rstack - command not found in PATH=${actualPath}`); - }); -}); diff --git a/packages/rstack/tests/setup/runtime-errors.test.ts b/packages/rstack/tests/setup/runtime-errors.test.ts new file mode 100644 index 0000000..ac85a13 --- /dev/null +++ b/packages/rstack/tests/setup/runtime-errors.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { installHooks } from '../../src/setup/install.ts'; +import { runHook, withRepository, writeHook } from './helpers.ts'; + +test('traces and reports hook failures and command lookup errors', () => { + withRepository((cwd) => { + writeHook(cwd, 'exit 23\n'); + expect(installHooks({ cwd }).status).toBe('installed'); + + const failed = runHook(cwd, '2'); + expect(failed.status).toBe(23); + expect(failed.stderr).toContain('+ sh -e'); + expect(`${failed.stdout}${failed.stderr}`).toContain( + 'Rstack - pre-commit hook failed (code 23)', + ); + + writeHook( + cwd, + `printf '%s\\n' "$PATH" > hook-path +missing-command +`, + ); + const missing = runHook(cwd); + const actualPath = readFileSync(path.join(cwd, 'hook-path'), 'utf8').trim(); + const output = `${missing.stdout}${missing.stderr}`; + + expect(missing.status).toBe(127); + expect(output).toContain('Rstack - pre-commit hook failed (code 127)'); + expect(output).toContain(`Rstack - command not found in PATH=${actualPath}`); + }); +}); diff --git a/packages/rstack/tests/setup/runtime.test.ts b/packages/rstack/tests/setup/runtime.test.ts new file mode 100644 index 0000000..4efb84e --- /dev/null +++ b/packages/rstack/tests/setup/runtime.test.ts @@ -0,0 +1,50 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { installHooks } from '../../src/setup/install.ts'; +import { runHook, withRepository, writeHook, writeInit } from './helpers.ts'; + +test('loads user init and project binaries', () => { + withRepository((cwd) => { + const binDirectory = path.join(cwd, 'node_modules', '.bin'); + mkdirSync(binDirectory, { recursive: true }); + writeInit(cwd, 'set -u\nexport RSTACK_INIT=loaded\n'); + + const command = path.join(binDirectory, 'rstack-hook-command'); + writeFileSync( + command, + `#!/usr/bin/env sh +printf 'ran\\n' > project-bin-ran +`, + ); + chmodSync(command, 0o755); + + writeHook( + cwd, + `printf '%s\\n' "$RSTACK_INIT" > init-ran +rstack-hook-command +`, + ); + + expect(installHooks({ cwd }).status).toBe('installed'); + + expect(runHook(cwd).status).toBe(0); + expect(readFileSync(path.join(cwd, 'init-ran'), 'utf8')).toBe('loaded\n'); + expect(readFileSync(path.join(cwd, 'project-bin-ran'), 'utf8')).toBe('ran\n'); + }); +}); + +test('skips user hooks when disabled by the environment or init', () => { + withRepository((cwd) => { + writeHook(cwd, 'echo ran >> hook-ran\n'); + expect(installHooks({ cwd }).status).toBe('installed'); + + expect(runHook(cwd, '0').status).toBe(0); + expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false); + + writeInit(cwd, 'set -u\nexport RSTACK_HOOKS=0\n'); + + expect(runHook(cwd).status).toBe(0); + expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false); + }); +});