Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
steps:
- uses: actions/setup-node@v7
with:
node-version: 18
node-version: 24
- uses: actions/checkout@v7
- name: npm install
run: npm install
Expand Down
48 changes: 23 additions & 25 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,50 +145,48 @@ GitForWindowsHelper/ # the Azure Function code
├── https-request.js / search.js / gently.js / org.js
└── validate-github-webhook.js

__tests__/ # Jest tests for the App (index + component-updates)
__tests__/ # Vitest tests (index + component-updates)
.github/workflows/deploy.yml # deploys the Function to Azure on push to main
embargoed-builds/ # SEPARATE git worktree (see below) -- do not edit here
```

## The `embargoed-builds` Worktree
## Secondary worktrees

`embargoed-builds/` is **not** part of the `main` branch. It is a separate
long-lived branch (`embargoed-builds`) that is typically checked out as a
[git worktree](https://git-scm.com/docs/git-worktree) nested in the
working directory. It is a parallel variant of this App used to build
**embargoed security releases**: it deploys to private Azure Blob Storage
(`wingit.blob.core.windows.net`) instead of the public `pacman-repo`, and
relies on self-hosted Windows/ARM64 runners.
There might be `vitest/`, `embargoed-builds/` and other worktrees that are
**not** part of the `main` branch. These are separate, and not to be
touched by you.

Consequences for agents:

- When working on `main`, **do not edit files under `embargoed-builds/`**.
It belongs to a different branch and is maintained separately; the two
variants are reconciled deliberately, not by editing both at once.
- Because Jest's default `testMatch` is `**/__tests__/**`, running
`npm test` from the repo root also collects
`embargoed-builds/__tests__/` when that worktree is present (you will
see four suites instead of two). To run only this App's tests, scope it:
`npx jest __tests__/`.
- When working on `main`, **do not edit files under `vitest/**`,
`embargoed-builds/**` or any other nested worktree. They belong to
separate work, and you must focus on the current work.
- Vitest collects only root `__tests__/*.test.js` files, so `npm test`
does not collect tests from any nested worktree.

## Building, Testing and Linting

There is no build step; the Function is deployed as-is. Verified commands:
There is no build step; the Function is deployed as-is. Use Node.js 24 for
test tooling, matching the PR check workflow. Verified commands:

- `npm run lint` — ESLint over `**/*.js` (config in `eslint.config.js`).
- `npm run lint:fix` — ESLint with `--fix`.
- `npm test` — Jest. To restrict to this App (excluding the
`embargoed-builds` worktree), use `npx jest __tests__/`.
- `npm test` -- Vitest, in non-watch mode, scoped to this App's tests.
- `npm test -- __tests__/index.test.js` -- Run only the webhook suite.

Always run lint and tests before committing.

## Testing Conventions

Import test APIs explicitly from `vitest`; the Function remains CommonJS.
Use `createMockRequire()` from `__tests__/mock-require.js` to load its
modules and install mock exports via `mockRequire()`. The fixture isolates
the Function's CommonJS cache and restores it after each suite.

The tests in `__tests__/index.test.js` exercise the webhook handler
end-to-end with heavily mocked dependencies. When adding `/deploy`-style
tests, keep these harness facts in mind:

- `afterEach` calls `jest.clearAllMocks()` and empties `dispatchedWorkflows`,
- `afterEach` calls `vi.clearAllMocks()` and empties `dispatchedWorkflows`,
so `toHaveBeenCalledTimes(...)` counts are per-test.
- The GitHub API is mocked in `mockGitHubApiRequest`. Looking up a PR's
head SHA goes through `GET .../pulls/<number>`, so **each PR number used
Expand Down Expand Up @@ -232,9 +230,9 @@ one-time setup.

## Validating Changes

1. **Lint and test**: `npm run lint` and `npx jest __tests__/`.
1. **Lint and test**: `npm run lint` and `npm test`.
2. **Dispatch contracts**: if you change a dispatched workflow filename or
a queued check-run name/summary, update `git-for-windows-automation`
(and re-check its `AGENTS.md`) in lockstep.
3. **Leave `embargoed-builds/` alone** unless you are deliberately working
on that branch.
3. **Leave nested worktrees alone** unless you are deliberately working
on their branch.
22 changes: 11 additions & 11 deletions __tests__/component-updates.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { expect, test, vi } from 'vitest'
import { createMockRequire } from './mock-require.js'

const { require, mockRequire } = createMockRequire()
const {
guessComponentUpdateDetails,
guessReleaseNotes,
Expand Down Expand Up @@ -55,13 +59,11 @@ test('guessComponentUpdateDetails()', () => {
)).toEqual({ package_name: 'pcre2', version: '10.49' })
})

let mockGithubApiRequest = jest.fn(() => {
let mockGithubApiRequest = vi.fn(() => {
return {
}
})
jest.mock('../GitForWindowsHelper/github-api-request', () => {
return mockGithubApiRequest
})
mockRequire('../GitForWindowsHelper/github-api-request', mockGithubApiRequest)

const mockFetchHTML = {
'https://cygwin.com': `<div>
Expand Down Expand Up @@ -95,7 +97,7 @@ const bogus32BitMSYS2RuntimeURL = 'https://raw.githubusercontent.com/git-for-win
const bogus64BitMSYS2RuntimeURL = 'https://raw.githubusercontent.com/git-for-windows/pacman-repo/refs/heads/x86_64/msys2-runtime-3.3-3.3.7-1-x86_64.pkg.tar.xz'
const missingOpenSSHURL = 'https://raw.githubusercontent.com/git-for-windows/pacman-repo/refs/heads/i686/openssh-9.5p1-1-i686.pkg.tar.xz'
const missingBashURL = 'https://raw.githubusercontent.com/git-for-windows/pacman-repo/refs/heads/x86_64/bash-5.2.020-1-x86_64.pkg.tar.xz'
const mockDoesURLReturn404 = jest.fn(url => [
const mockDoesURLReturn404 = vi.fn(url => [
missingURL,
missingAarch64URL,
missingMinTTYURL,
Expand All @@ -104,16 +106,14 @@ const mockDoesURLReturn404 = jest.fn(url => [
missingOpenSSHURL,
missingBashURL
].includes(url))
jest.mock('../GitForWindowsHelper/https-request', () => {
return {
doesURLReturn404: mockDoesURLReturn404,
fetchHTML: jest.fn(url => mockFetchHTML[url])
}
mockRequire('../GitForWindowsHelper/https-request', {
doesURLReturn404: mockDoesURLReturn404,
fetchHTML: vi.fn(url => mockFetchHTML[url])
})


test('guessReleaseNotes()', async () => {
const context = { log: jest.fn() }
const context = { log: vi.fn() }
expect(await guessReleaseNotes(context, {
labels: [{ name: 'component-update' }],
title: '[New bash version] 3 new items',
Expand Down
84 changes: 45 additions & 39 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { afterEach, expect, test, vi } from 'vitest'
import { createMockRequire } from './mock-require.js'

const { require, mockRequire } = createMockRequire()
const index = require('../GitForWindowsHelper/index')
const crypto = require('crypto')

process.env['GITHUB_WEBHOOK_SECRET'] = 'for-testing'

test('reject requests other than webhook payloads', async () => {
const context = {
log: jest.fn(),
log: vi.fn(),
req: {
method: 'GET'
}
Expand All @@ -26,7 +30,7 @@ test('reject requests other than webhook payloads', async () => {

await expectInvalidWebhook('Unexpected method: GET')

context.log = jest.fn()
context.log = vi.fn()
context.req.method = 'POST'
context.req.headers = {
'content-type': 'text/plain'
Expand All @@ -44,18 +48,22 @@ test('reject requests other than webhook payloads', async () => {
await expectInvalidWebhook('Incorrect X-Hub-Signature')
})

let mockGetInstallationAccessToken = jest.fn(() => 'installation-access-token')
jest.mock('../GitForWindowsHelper/get-installation-access-token', () => {
return mockGetInstallationAccessToken
})
let mockGetInstallationAccessToken = vi.fn(() => 'installation-access-token')
mockRequire(
'../GitForWindowsHelper/get-installation-access-token',
mockGetInstallationAccessToken
)

let mockGitHubApiRequestAsApp = jest.fn()
jest.mock('../GitForWindowsHelper/github-api-request-as-app', () => {
return mockGitHubApiRequestAsApp
})
let mockGitHubApiRequestAsApp = vi.fn()
mockRequire(
'../GitForWindowsHelper/github-api-request-as-app',
mockGitHubApiRequestAsApp
)

const dispatchedWorkflows = []
let mockGitHubApiRequest = jest.fn((_context, _token, method, requestPath, payload) => {
let mockGitHubApiRequest = vi.fn((
_context, _token, method, requestPath, payload
) => {
if (method === 'POST' && requestPath.endsWith('/comments')) return {
id: -124,
html_url: `new-comment-url-${payload.body}`
Expand Down Expand Up @@ -207,12 +215,10 @@ The \`git-artifacts-mingw64\` workflow run [was started](dispatched-workflow-git
}
throw new Error(`Unhandled ${method}-${requestPath}-${JSON.stringify(payload)}`)
})
jest.mock('../GitForWindowsHelper/github-api-request', () => {
return mockGitHubApiRequest
})
mockRequire('../GitForWindowsHelper/github-api-request', mockGitHubApiRequest)

afterEach(() => {
jest.clearAllMocks()
vi.clearAllMocks()
dispatchedWorkflows.splice(0, dispatchedWorkflows.length) // empty the array
})

Expand All @@ -229,7 +235,7 @@ const makeContext = (body, headers) => {
const rawBody = JSON.stringify(body)
const sha256 = crypto.createHmac('sha256', process.env['GITHUB_WEBHOOK_SECRET']).update(rawBody).digest('hex')
return {
log: jest.fn(),
log: vi.fn(),
req: {
body,
headers: {
Expand Down Expand Up @@ -321,12 +327,13 @@ testIssueComment('/hi', async (context) => {
])
})

let mockGetInstallationIDForRepo = jest.fn(() => 'installation-id')
jest.mock('../GitForWindowsHelper/get-installation-id-for-repo', () => {
return mockGetInstallationIDForRepo
})
let mockGetInstallationIDForRepo = vi.fn(() => 'installation-id')
mockRequire(
'../GitForWindowsHelper/get-installation-id-for-repo',
mockGetInstallationIDForRepo
)

let mockSearchIssues = jest.fn((_context, searchTerms) => {
let mockSearchIssues = vi.fn((_context, searchTerms) => {
if (searchTerms.indexOf('release-git') > 0) return [{
number: 765,
author_association: 'MEMBER',
Expand All @@ -336,10 +343,8 @@ let mockSearchIssues = jest.fn((_context, searchTerms) => {
}]
return []
})
jest.mock('../GitForWindowsHelper/search', () => {
return {
searchIssues: mockSearchIssues
}
mockRequire('../GitForWindowsHelper/search', {
searchIssues: mockSearchIssues
})

testIssueComment('/open pr', {
Expand Down Expand Up @@ -544,9 +549,11 @@ The workflow run [was started](dispatched-workflow-updpkgsums.yml).`
})
})

let mockQueueCheckRun = jest.fn(() => 'check-run-id')
let mockUpdateCheckRun = jest.fn()
let mockListCheckRunsForCommit = jest.fn((_context, _token, _owner, _repo, rev, checkRunName) => {
let mockQueueCheckRun = vi.fn(() => 'check-run-id')
let mockUpdateCheckRun = vi.fn()
let mockListCheckRunsForCommit = vi.fn((
_context, _token, _owner, _repo, rev, checkRunName
) => {
const app = {
slug: 'gitforwindowshelper'
}
Expand Down Expand Up @@ -627,12 +634,10 @@ let mockListCheckRunsForCommit = jest.fn((_context, _token, _owner, _repo, rev,
}]
return []
})
jest.mock('../GitForWindowsHelper/check-runs', () => {
return {
queueCheckRun: mockQueueCheckRun,
updateCheckRun: mockUpdateCheckRun,
listCheckRunsForCommit: mockListCheckRunsForCommit
}
mockRequire('../GitForWindowsHelper/check-runs', {
queueCheckRun: mockQueueCheckRun,
updateCheckRun: mockUpdateCheckRun,
listCheckRunsForCommit: mockListCheckRunsForCommit
})

testIssueComment('/deploy', {
Expand Down Expand Up @@ -866,10 +871,11 @@ The workflow run [was started](dispatched-workflow-build-and-deploy.yml).`)
})

const missingURL = 'https://raw.githubusercontent.com/git-for-windows/pacman-repo/refs/heads/x86_64/mingw-w64-x86_64-git-lfs-3.4.0-1-any.pkg.tar.xz'
const mockDoesURLReturn404 = jest.fn(url => url === missingURL)
const mockFetchHTML = jest.fn(() => 'pkgver=10.48\n')
jest.mock('../GitForWindowsHelper/https-request', () => {
return { doesURLReturn404: mockDoesURLReturn404, fetchHTML: mockFetchHTML }
const mockDoesURLReturn404 = vi.fn(url => url === missingURL)
const mockFetchHTML = vi.fn(() => 'pkgver=10.48\n')
mockRequire('../GitForWindowsHelper/https-request', {
doesURLReturn404: mockDoesURLReturn404,
fetchHTML: mockFetchHTML
})

testIssueComment('/add release note', {
Expand Down Expand Up @@ -1016,7 +1022,7 @@ The \`tag-git\` workflow run [was started](dispatched-workflow-tag-git.yml)`,
snapshot: 'false'
})

jest.clearAllMocks()
vi.clearAllMocks()
dispatchedWorkflows.splice(0, dispatchedWorkflows.length) // empty the array

// with existing `tag-git` run
Expand Down
30 changes: 30 additions & 0 deletions __tests__/mock-require.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterAll } from 'vitest'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'

export const createMockRequire = () => {
const require = createRequire(import.meta.url)
const directory = fileURLToPath(
new URL('../GitForWindowsHelper/', import.meta.url)
)
const originals = new Map()
for (const [id, module] of Object.entries(require.cache)) {
if (id.startsWith(directory)) {
originals.set(id, module)
delete require.cache[id]
}
}

afterAll(() => {
for (const id of Object.keys(require.cache)) {
if (id.startsWith(directory)) delete require.cache[id]
}
for (const [id, module] of originals) require.cache[id] = module
})

const mockRequire = (path, exports) => {
const id = require.resolve(path)
require.cache[id] = { exports, loaded: true }
}
return { require, mockRequire }
}
7 changes: 1 addition & 6 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
const js = require("@eslint/js")
const jest = require("eslint-plugin-jest")
const globals = require("globals")

module.exports = [
js.configs.recommended,
{
plugins: {
jest: jest
},
languageOptions: {
ecmaVersion: 12,
sourceType: "module",
globals: {
...globals.node,
...globals.es6,
...globals.jest
...globals.es6
}
},
rules: {
Expand Down
Loading
Loading