diff --git a/.eslintignore b/.eslintignore index 410d575..1db1571 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,5 @@ build/ coverage/ scripts/ jest.config.ts +jest.setup.js **/generated/ \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b50e63a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,139 @@ +# AI Coding Agent Instructions for mock-github + +## Project Overview +**mock-github** is a testing library that provides tools to mock GitHub environments locally. It enables testing GitHub Actions and API calls without polluting real repositories or hitting rate limits. + +Two core abstractions: +1. **Moctokit**: Mocks GitHub REST API endpoints using OpenAPI spec (via `nock`) +2. **MockGithub**: Mocks full GitHub environment with repositories, branches, files, actions, and env vars + +## Architecture & Key Components + +### Layer 1: Endpoint Mocking (Foundation) +- `src/endpoint-mocker/abstract-endpoint-mocker.ts`: Base class using `nock` for HTTP interception +- `src/endpoint-mocker/request/abstract-request-mocker.ts`: Generic request parameter matching +- `src/endpoint-mocker/response/abstract-response-mocker.ts`: Response building with status/headers + +### Layer 2: API Mocking (Generated) +- `src/moctokit/moctokit.ts`: Extends `EndpointMocker`, wraps auto-generated endpoint methods +- `src/moctokit/generated/endpoint-request.ts`: **AUTO-GENERATED** from GitHub OpenAPI spec (~1817 lines) + - Maps 200+ GitHub API endpoints to `MoctokitRequestMocker` instances + - **Never manually edit** - regenerate via `npm run update:endpoint-requests` +- `src/moctokit/generated/endpoint-details.ts`: OpenAPI metadata for each endpoint + +### Layer 3: GitHub Environment Mocking +- `src/github/github-mocker.ts`: Orchestrates setup/teardown of repositories, env vars, actions +- `src/github/repository/repository-mocker.ts`: Creates local git repos via `simple-git` +- `src/github/env/env-mocker.ts`: Manages process environment variables +- `src/github/action/action-mocker.ts`: Simulates GitHub Action execution context + +### Type System (Critical for Development) +- Uses **OpenAPI path types** from generated endpoint details +- Generic constraints like `Path extends keyof paths, Method extends keyof paths[Path]` +- `src/moctokit/response/response-mocker.types.ts`: Complex conditional types for response validation +- `.types.ts` files excluded from coverage - contain domain models, not business logic + +## Critical Developer Workflows + +### Build & Dependencies +```bash +npm run build # TypeScript → JavaScript, alias resolution via tsc-alias +npm test # Jest with 90% coverage threshold +npm run test:report # Coverage report + SonarQube XML output +npm run lint # ESLint with auto-fix on commit via husky +``` + +### Updating Generated Code +```bash +npm run update:api-spec # Fetch latest GitHub OpenAPI spec +npm run update:endpoint-details # Parse endpoints from spec +npm run update:endpoint-requests # Generate endpoint method bindings +npm run update-all # Run all three steps +``` + +Scripts are in `scripts/` directory - regenerate when GitHub API changes. + +## Project-Specific Patterns + +### 1. **Module Path Aliases** +- Use `@mg/*` imports (configured in `tsconfig.json` paths) +- Example: `import { Moctokit } from "@mg/moctokit/moctokit"` +- Resolves to `src/*` during development, `build/src/*` in compiled output + +### 2. **Abstract Base Classes for Extension** +- `EndpointMocker`, `RequestMocker`, `ResponseMocker` are abstract - meant to be extended +- `MoctokitRequestMocker` and `MoctokitResponseMocker` extend these for GitHub API specifics +- Pattern: Abstract → Concrete implementation → Usage via interface + +### 3. **Config Validation with AJV** +- `MockGithub` constructor validates config against `GithubConfigSchema` (JSON Schema) +- Schema defined in `src/github/schema/` directory +- Config can be JSON file path (string) or object - validated before use + +### 4. **Setup/Teardown Lifecycle** +- `MockGithub` requires explicit `setup()` and `teardown()` calls +- Setup order matters: repositories → env vars → actions +- Throws if methods called before setup (e.g., `repo` getter checks `hasCalledSetup`) + +### 5. **Repository State Management** +- `RepositoryState` tracks branches, files, remotes (origin/upstream) +- State exposed via `repo.getState()`, `repo.getBranchState()`, etc. +- Branches can be local or pushed - tracked separately in `BranchState` + +### 6. **File Organization by Responsibility** +``` +src/ +├── endpoint-mocker/ # Generic HTTP mocking layer +├── moctokit/ # GitHub API-specific mocking +├── github/ # Full GitHub environment +│ ├── repository/ # Git repo + state management +│ ├── action/ # GitHub Actions simulation +│ ├── env/ # Environment variables +│ └── schema/ # JSON validation schemas +``` + +## Integration Points & Dependencies + +### External Libraries +- `nock`: HTTP mocking (≥1.0.0 incompatible with Node 18 native fetch) +- `simple-git`: Git operations (local repo creation/manipulation) +- `@octokit/rest`: Type definitions (v19, not runtime) +- `ajv`: JSON Schema validation +- `ts-jest`: TypeScript testing + +### Cross-Component Communication +1. **Config Flow**: JSON config → validated → distributed to Action/Env/Repo mockers +2. **Setup Coordination**: `MockGithub.setup()` → parallel repo/env setup → then action setup +3. **Query Interface**: `repo.getState()` returns `State` (path, branches, files) + +## Testing Patterns + +### Mocking Tests +- Located in `test/moctokit/moctokit.test.ts` and `test/github/` +- Create instance → configure mock → execute client → assert response +- Use `repeat` option in `reply()` for multiple matching calls +- Example: `moctokit.rest.repos.get({owner: "kie"}).reply({status: 200, data: {...}})` + +### Assertions +- Verify both status and data payload +- Repository tests check git history, branches, file contents via state queries +- Coverage threshold enforced: 90% branches/functions/lines/statements + +## Common Pitfalls & Guidelines + +- **Don't edit generated files**: `src/moctokit/generated/*` is auto-generated - run `npm run update:*` scripts instead +- **Always setup/teardown MockGithub**: Forgetting causes hard-to-debug state pollution +- **Path aliases in imports**: Use `@mg/` consistently, don't mix relative paths +- **Type-first development**: Leverage generic constraints in request/response mockers - they catch issues early +- **Config schema**: Validate before instantiating - bad config fails at constructor, not later + +## Key Files to Reference + +| File | Purpose | +|------|---------| +| `src/index.ts` | Public API exports | +| `src/github/github-mocker.ts` | Main entry point for environment mocking | +| `src/moctokit/moctokit.ts` | Main entry point for API mocking | +| `jest.config.ts` | Coverage thresholds (90%), path aliases, `ts-jest` config | +| `package.json` | Scripts, build output points, dependencies | +| `scripts/endpoint-details.js` | Generates endpoint metadata from OpenAPI | diff --git a/test/github/action/archive/archive-mocker.test.ts b/test/github/action/archive/archive-mocker.test.ts index fd34d46..ae2141d 100644 --- a/test/github/action/archive/archive-mocker.test.ts +++ b/test/github/action/archive/archive-mocker.test.ts @@ -67,6 +67,10 @@ describe("teardown", () => { test("archive store was not created", async () => { const storePath = path.join(__dirname, "store"); + // Clean up if it exists from previous test runs + if (existsSync(storePath)) { + await rm(storePath, { recursive: true, force: true }); + } mkdirSync(storePath); const archiveMocker = new ArchiveArtifactsMocker(__dirname, "8080"); await archiveMocker.setup(); diff --git a/test/github/repository/branches/repository-branches.test.ts b/test/github/repository/branches/repository-branches.test.ts index 6765dcc..f0fefe7 100644 --- a/test/github/repository/branches/repository-branches.test.ts +++ b/test/github/repository/branches/repository-branches.test.ts @@ -2,6 +2,8 @@ import path from "path"; import simpleGit, { SimpleGit } from "simple-git"; import { RepositoryBranches } from "@mg/github/repository/branches/repository-branches"; import { RepositoryMocker } from "@mg/github/repository/repository-mocker"; +import { existsSync } from "fs"; +import { rm } from "fs/promises"; let repoMocker: RepositoryMocker; let branchCreator: RepositoryBranches; @@ -23,6 +25,15 @@ beforeEach(async () => { afterEach(async () => { await repoMocker.teardown(); + // Extra cleanup to ensure repo directory is removed + const repoDir = path.join(__dirname, "repo"); + if (existsSync(repoDir)) { + try { + await rm(repoDir, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + } }); describe("setup local branches", () => { diff --git a/test/github/repository/history/repository-histor-mocker.test.ts b/test/github/repository/history/repository-histor-mocker.test.ts index 0b4e5aa..69c02a0 100644 --- a/test/github/repository/history/repository-histor-mocker.test.ts +++ b/test/github/repository/history/repository-histor-mocker.test.ts @@ -37,6 +37,15 @@ beforeEach(async () => { afterEach(async () => { await repoMocker.teardown(); + // Extra cleanup to ensure repo directory is removed + const repoDir = path.join(__dirname, "repo"); + if (existsSync(repoDir)) { + try { + await rm(repoDir, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + } }); describe.each([ diff --git a/test/github/repository/repository-mocker.test.ts b/test/github/repository/repository-mocker.test.ts index 553902b..ff39c0b 100644 --- a/test/github/repository/repository-mocker.test.ts +++ b/test/github/repository/repository-mocker.test.ts @@ -13,6 +13,18 @@ import { const setupPath = __dirname; +afterEach(async () => { + // Clean up any repositories created during tests + const repoPath = path.join(setupPath, "repo"); + if (existsSync(repoPath)) { + try { + await rm(repoPath, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + } +}); + describe("setup", () => { test("no repositories", async () => { const repoMocker = new RepositoryMocker({}, setupPath); diff --git a/test/moctokit/moctokit.test.ts b/test/moctokit/moctokit.test.ts index ebbac83..25790bf 100644 --- a/test/moctokit/moctokit.test.ts +++ b/test/moctokit/moctokit.test.ts @@ -1,6 +1,11 @@ import { Moctokit } from "@mg/moctokit/moctokit"; import { Octokit } from "@octokit/rest"; +afterEach(() => { + const moctokit = new Moctokit(); + moctokit.cleanAll(); +}); + test("with default base url", async () => { const moctokit = new Moctokit(); moctokit.rest.repos diff --git a/test/moctokit/request-response-mocker.test.ts b/test/moctokit/request-response-mocker.test.ts index 3d10f87..ee99796 100644 --- a/test/moctokit/request-response-mocker.test.ts +++ b/test/moctokit/request-response-mocker.test.ts @@ -2,12 +2,17 @@ import axios from "axios"; import { EndpointMethod } from "@mg/endpoint-mocker/endpoint-mocker.types"; import { MoctokitRequestMocker } from "@mg/moctokit/request/request-mocker"; +import nock from "nock"; const url = "http://localhost:8000"; const instance = axios.create({ baseURL: url, }); +afterEach(() => { + nock.cleanAll(); +}); + describe.each(["get", "post", "delete", "put", "patch"])( "Method - %p", (method: string) => {