diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92dbb84a..295289e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,7 @@ jobs: cache: npm - run: npm ci - run: npm run build + - run: npm run typecheck - run: npm run lint - run: npm run test:coverage - name: Coverage summary diff --git a/__tests__/adapters/app/webhook.test.ts b/__tests__/adapters/app/webhook.test.ts index 342a12c3..330e3ace 100644 --- a/__tests__/adapters/app/webhook.test.ts +++ b/__tests__/adapters/app/webhook.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import type { staticTokens } from './testApp' +import { describe, expect, it } from 'vitest' import { receiveWebhook } from '../../../src/adapters/app/webhook' import issueCommentEvent from '../../fixtures/issues/issueCommentEvent.json' import { receiver, signedRequest, withInstallation } from './testApp' @@ -18,7 +19,7 @@ describe('receiveWebhook', () => { expect(r.dispatched).toHaveLength(1) expect(r.dispatched[0]).toMatchObject({ eventName: 'issue_comment', repo: { owner: 'Codertocat', repo: 'Hello-World' } }) expect(r.dispatched[0]!.payload.installation).toEqual({ id: 7 }) - expect((r.tokens as { requested: number[] }).requested).toEqual([7]) + expect((r.tokens as ReturnType).requested).toEqual([7]) }) it('rejects a bad signature with 401 and dispatches nothing', async () => { diff --git a/__tests__/bundle/fakeGithub.ts b/__tests__/bundle/fakeGithub.ts index 93dd6406..7ff48b7e 100644 --- a/__tests__/bundle/fakeGithub.ts +++ b/__tests__/bundle/fakeGithub.ts @@ -143,7 +143,7 @@ export async function start(): Promise { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() as AddressInfo - return { + const github: FakeGithub = { url: `http://127.0.0.1:${port}`, requests, unmatched, @@ -154,7 +154,7 @@ export async function start(): Promise { return unmatched.filter(r => !allowances.some(a => matches(a, r))) }, assertNoUnexpectedRequests() { - const unexpected = this.unexpectedRequests() + const unexpected = github.unexpectedRequests() if (unexpected.length > 0) { const listing = unexpected.map(r => ` ${r.method} ${r.path}`).join('\n') throw new Error(`the fake GitHub API answered ${unexpected.length} request(s) no route or allowance covers:\n${listing}`) @@ -197,6 +197,7 @@ export async function start(): Promise { }) }, } + return github } function mergeQueueRoute(queue: MergeQueueFixture): Route { diff --git a/__tests__/bundle/sweepArms.test.ts b/__tests__/bundle/sweepArms.test.ts index 7bdf0698..24f94b36 100644 --- a/__tests__/bundle/sweepArms.test.ts +++ b/__tests__/bundle/sweepArms.test.ts @@ -157,7 +157,7 @@ describe('dist/index.js schedule sweep job arms', () => { // requested_reviewers is optional on the pulls listing item (sweep.ts:153 `pr.requested_reviewers ?? []`): a fresh // pull request listed without it has no requested reviewers, so blunderbuss reads the one review and asks for two it('a fresh pull request listed without requested_reviewers: the one review is read and two reviewers are requested', async () => { - const pr = forkPr() + const pr: Partial> = forkPr() delete pr.requested_reviewers routeSweep(pr) diff --git a/__tests__/configurationDocs.test.ts b/__tests__/configurationDocs.test.ts index a7dbdb83..ba5aefd1 100644 --- a/__tests__/configurationDocs.test.ts +++ b/__tests__/configurationDocs.test.ts @@ -72,11 +72,11 @@ const example = parseProwConfig(docPath, yamlBlock('`prow.yaml`')) /** what the code resolves for a section nobody configured, keyed like the document headings */ const resolvedDefaults: Record> = { - tide: resolveTide({}, ''), + tide: { ...resolveTide({}, '') }, hold: { label: resolveHoldLabel({}) }, - blunderbuss: blunderbussSettings(empty), - approve: approveSettings(empty), - lgtm: lgtmSettings(empty), + blunderbuss: { ...blunderbussSettings(empty) }, + approve: { ...approveSettings(empty) }, + lgtm: { ...lgtmSettings(empty) }, sweep: { lookback: defaultSweepLookback }, authorization: { ...resolveAuthorization({}) }, } @@ -89,7 +89,7 @@ describe(`${docPath} prow.yaml example`, () => { }) it.each(['tide', 'hold', 'approve', 'lgtm', 'sweep', 'authorization'])('spells out the code defaults of %s, as its comment claims', (key) => { - expect(example[key as keyof typeof resolvedDefaults]).toEqual(resolvedDefaults[key]) + expect(example[key as keyof ProwConfig]).toEqual(resolvedDefaults[key]) }) it('names the tide.labels default of a repository with OWNERS files', () => { diff --git a/__tests__/cronJobTest/labelSync.test.ts b/__tests__/cronJobTest/labelSync.test.ts index 2cafc6aa..030011ca 100644 --- a/__tests__/cronJobTest/labelSync.test.ts +++ b/__tests__/cronJobTest/labelSync.test.ts @@ -289,7 +289,8 @@ describe('label-sync job', () => { it('fails the run when the configuration cannot be loaded', async () => { server.use( http.get(`${repo}/contents/.github%2Fprow.yaml`, utils.mockResponse(500, { message: 'boom' })), - ...utils.noOrgOrRepoConfigExcept('.github/prow.yaml'), + // end the organization tier on its first probe: its chain would otherwise outlive this test (see configFoundAt) + ...utils.configFoundAt('Codertocat/.project:prow.yaml', '.github/prow.yaml'), ) const writes = recordWrites([]) const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) @@ -318,7 +319,7 @@ describe('label-sync job', () => { const real = new Octokit({ auth: 'some-token', baseUrl: utils.api }) const refused = vi.fn().mockRejectedValue('offline') const issues = Object.create(real.issues, { - createLabel: { value: (params: { name: string }) => (params.name === 'kind/bug' ? refused() : real.issues.createLabel(params)) }, + createLabel: { value: (params: NonNullable[0]>) => (params.name === 'kind/bug' ? refused() : real.issues.createLabel(params)) }, }) return Object.create(real, { issues: { value: issues } }) }) diff --git a/__tests__/cronJobTest/lgtm.test.ts b/__tests__/cronJobTest/lgtm.test.ts index aafa346d..e0d41178 100644 --- a/__tests__/cronJobTest/lgtm.test.ts +++ b/__tests__/cronJobTest/lgtm.test.ts @@ -179,8 +179,10 @@ describe('cronLgtm', () => { // Instead, we use it to gain the repo owner and url const context = new utils.MockContext(pullReqOpenedEvent) - listPullReqs[0].labels[0].name = 'lgtm' - listPullReqs[0].labels.push({ + // a copy: the shared fixture is what the default handlers above serve to every other test + const heldPrs = structuredClone(listPullReqs) + heldPrs[0].labels[0].name = 'lgtm' + heldPrs[0].labels.push({ id: 1, node_id: '123', url: 'https://api.github.com/repos/octocat/Hello-World/labels/hold', @@ -189,6 +191,7 @@ describe('cronLgtm', () => { color: 'f29513', default: true, }) + routePulls(heldPrs) const observeReq = new utils.ObserveRequest() server.use( diff --git a/__tests__/issueCommentTest/assign.test.ts b/__tests__/issueCommentTest/assign.test.ts index 9d1b7e2a..57148d0e 100644 --- a/__tests__/issueCommentTest/assign.test.ts +++ b/__tests__/issueCommentTest/assign.test.ts @@ -21,6 +21,8 @@ afterAll(() => server.close()) describe('/assign', () => { beforeEach(() => { utils.setupActionsEnv('/assign') + // the tests below rewrite the shared fixture's comment; start each from the file's own body + issueCommentEventAssign.comment.body = '/assign' }) it('handles self assigning with comment /assign', async () => { diff --git a/__tests__/issueCommentTest/handleIssueComment.test.ts b/__tests__/issueCommentTest/handleIssueComment.test.ts index 371c39f4..5eef0b6b 100644 --- a/__tests__/issueCommentTest/handleIssueComment.test.ts +++ b/__tests__/issueCommentTest/handleIssueComment.test.ts @@ -28,6 +28,8 @@ beforeAll(() => server.listen(utils.failOnUnhandledRequest)) // the dispatch tests below mock every command; the sweep that follows a command is stubbed the same way let sweepStubs: ReturnType[] beforeEach(() => { + // the dispatch tests rewrite the shared fixture's comment; start each from the file's own body + issueCommentEvent.comment.body = 'Meow meow!' sweepStubs = [ vi.spyOn(requireMatchingLabel, 'checkRequiredLabels').mockResolvedValue(), vi.spyOn(tide, 'tideOnComment').mockResolvedValue(), @@ -405,7 +407,8 @@ it.each([ ] as const)('listing only %s also enables %s', async (alias, base, mod, fn) => { utils.setupActionsEnv(alias) - const spy = vi.spyOn(mod, fn).mockImplementation(() => Promise.resolve()) + // the three modules export differently named handlers of one shape; the table pairs each with its own name + const spy = vi.spyOn(mod as Record Promise>, fn).mockImplementation(() => Promise.resolve()) const setFailed = vi.spyOn(core, 'setFailed').mockImplementation(() => {}) issueCommentEvent.comment.body = base diff --git a/__tests__/issueCommentTest/meowImageEdges.test.ts b/__tests__/issueCommentTest/meowImageEdges.test.ts index b2e811e4..db23f8d7 100644 --- a/__tests__/issueCommentTest/meowImageEdges.test.ts +++ b/__tests__/issueCommentTest/meowImageEdges.test.ts @@ -42,7 +42,7 @@ function contextFor(body: string) { // prefix, a url with only a username or only a password is refused, and the // length bound is inclusive at 4096. describe('/meow image validation edges', () => { - let createComment: ReturnType> + let createComment: MockInstance beforeEach(() => { utils.setupActionsEnv('/meow') diff --git a/__tests__/issueCommentTest/milestone.test.ts b/__tests__/issueCommentTest/milestone.test.ts index 3c610f15..1d230ef1 100644 --- a/__tests__/issueCommentTest/milestone.test.ts +++ b/__tests__/issueCommentTest/milestone.test.ts @@ -198,7 +198,7 @@ describe('/milestone', () => { }) it('still sends the clear when the issue has no milestone', async () => { - const payload = structuredClone(issueCommentEvent) as typeof issueCommentEvent & { issue: { milestone: unknown } } + const payload = structuredClone(issueCommentEvent) as Omit & { issue: Omit & { milestone: unknown } } payload.comment.body = '/milestone clear' payload.issue.milestone = null diff --git a/__tests__/issueCommentTest/unassign.test.ts b/__tests__/issueCommentTest/unassign.test.ts index 545ff1bd..fc91530d 100644 --- a/__tests__/issueCommentTest/unassign.test.ts +++ b/__tests__/issueCommentTest/unassign.test.ts @@ -21,6 +21,8 @@ afterAll(() => server.close()) describe('/unassign', () => { beforeEach(() => { utils.setupActionsEnv('/unassign') + // the tests below rewrite the shared fixture's comment; start each from the file's own body + issueCommentEventUnassign.comment.body = '/unassign' }) it('handles self unassignment with comment /unassign', async () => { diff --git a/__tests__/missingNumberMessage.test.ts b/__tests__/missingNumberMessage.test.ts index 7ac8dbcd..1ec2cf56 100644 --- a/__tests__/missingNumberMessage.test.ts +++ b/__tests__/missingNumberMessage.test.ts @@ -1,4 +1,4 @@ -import type { Context } from '@actions/github/lib/context' +import type { Context } from '../src/utils/context' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' diff --git a/__tests__/platform/portability.test.ts b/__tests__/platform/portability.test.ts index 8a4e1dc1..1b7036f9 100644 --- a/__tests__/platform/portability.test.ts +++ b/__tests__/platform/portability.test.ts @@ -44,7 +44,7 @@ describe('a platform other than GitHub Actions', () => { const actions = platform() beforeAll(() => { - server.listen({ onUnhandledRequest: 'error' }) + server.listen(utils.failOnUnhandledRequest) }) afterEach(() => { server.resetHandlers() diff --git a/__tests__/platform/runState.test.ts b/__tests__/platform/runState.test.ts index ba7bce1e..7057ebfb 100644 --- a/__tests__/platform/runState.test.ts +++ b/__tests__/platform/runState.test.ts @@ -21,7 +21,7 @@ function withState(): Platform { } describe('runState', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeAll(() => server.listen(utils.failOnUnhandledRequest)) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) diff --git a/__tests__/plugins/approve.test.ts b/__tests__/plugins/approve.test.ts index a891fdb1..bdbf5554 100644 --- a/__tests__/plugins/approve.test.ts +++ b/__tests__/plugins/approve.test.ts @@ -16,6 +16,7 @@ function pullOwners(ownersFiles: Record, files: string[], author return { number: 1, baseSha: 'basesha', + headSha: 'headsha', author, draft: false, open: true, diff --git a/__tests__/plugins/approveErrorPaths.test.ts b/__tests__/plugins/approveErrorPaths.test.ts index a6caf75e..d9743e6f 100644 --- a/__tests__/plugins/approveErrorPaths.test.ts +++ b/__tests__/plugins/approveErrorPaths.test.ts @@ -28,6 +28,7 @@ function pullOwners(ownersFiles: Record, files: string[], author return { number: 1, baseSha: 'basesha', + headSha: 'headsha', author, draft: false, open: true, diff --git a/__tests__/plugins/approveGuards.test.ts b/__tests__/plugins/approveGuards.test.ts index 59050605..39fcd8cc 100644 --- a/__tests__/plugins/approveGuards.test.ts +++ b/__tests__/plugins/approveGuards.test.ts @@ -46,6 +46,7 @@ function pullOwners(ownersFiles: Record, files: string[], author return { number: 1, baseSha: 'basesha', + headSha: 'headsha', author, draft: false, open: true, diff --git a/__tests__/plugins/blunderbuss.test.ts b/__tests__/plugins/blunderbuss.test.ts index 5f9b4dce..4db52de3 100644 --- a/__tests__/plugins/blunderbuss.test.ts +++ b/__tests__/plugins/blunderbuss.test.ts @@ -8,6 +8,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { handleIssueComment } from '../../src/issueComment/handleIssueComment' import { autoCc, blunderbuss, blunderbussSettings, pickReviewers } from '../../src/plugins/blunderbuss' import { handlePullReq, pullRequestHandlers } from '../../src/pullReq/handlePullReq' +import { mergeProwConfig } from '../../src/utils/config' import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json' import labelFileContents from '../fixtures/labels/labelFileContentsResp.json' import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' @@ -117,7 +118,7 @@ describe('pickReviewers', () => { describe('blunderbussSettings', () => { it('applies the defaults', () => { - expect(blunderbussSettings({ labels: {}, require_matching_label: [], tide: {}, hold: {}, blunderbuss: {}, sources: [] })).toEqual({ + expect(blunderbussSettings({ ...mergeProwConfig({}, {}), sources: [] })).toEqual({ request_count: 2, max_request_count: undefined, exclude_approvers: false, @@ -128,11 +129,11 @@ describe('blunderbussSettings', () => { }) it('reads delay as milliseconds', () => { - expect(blunderbussSettings({ labels: {}, require_matching_label: [], tide: {}, hold: {}, blunderbuss: { delay: '1h30m' }, sources: [] }).delay).toBe(90 * 60_000) + expect(blunderbussSettings({ ...mergeProwConfig({}, {}), blunderbuss: { delay: '1h30m' }, sources: [] }).delay).toBe(90 * 60_000) }) it('lowercases ignore_authors', () => { - expect(blunderbussSettings({ labels: {}, require_matching_label: [], tide: {}, hold: {}, blunderbuss: { ignore_authors: ['Dependabot[bot]'] }, sources: [] }).ignore_authors).toEqual(['dependabot[bot]']) + expect(blunderbussSettings({ ...mergeProwConfig({}, {}), blunderbuss: { ignore_authors: ['Dependabot[bot]'] }, sources: [] }).ignore_authors).toEqual(['dependabot[bot]']) }) }) diff --git a/__tests__/plugins/lgtmBinding.test.ts b/__tests__/plugins/lgtmBinding.test.ts index 19549b9f..8203688d 100644 --- a/__tests__/plugins/lgtmBinding.test.ts +++ b/__tests__/plugins/lgtmBinding.test.ts @@ -20,7 +20,7 @@ afterAll(() => server.close()) const repo = `${utils.api}/repos/Codertocat/Hello-World` const headSha = pullReqOpenedEvent.pull_request.head.sha -function labeled(label: string, sender: Record = { login: 'alice', type: 'User' }, action = 'labeled') { +function labeled(label: string, sender: { login: string, type: string } = { login: 'alice', type: 'User' }, action = 'labeled') { return new utils.MockContext({ ...pullReqOpenedEvent, action, label: { name: label }, sender }) } @@ -114,7 +114,7 @@ describe('lgtmOnPullRequest', () => { }) it('throws when the payload has no head', async () => { - await expect(lgtmOnPullRequest(new utils.MockContext({ action: 'labeled', label: { name: 'lgtm' }, sender: { login: 'alice' } }))).rejects.toThrow('missing pull request head') + await expect(lgtmOnPullRequest(new utils.MockContext({ action: 'labeled', label: { name: 'lgtm' }, sender: { login: 'alice', type: 'User' } }))).rejects.toThrow('missing pull request head') }) }) diff --git a/__tests__/plugins/requireMatchingLabel.test.ts b/__tests__/plugins/requireMatchingLabel.test.ts index 20640cf2..a52e2216 100644 --- a/__tests__/plugins/requireMatchingLabel.test.ts +++ b/__tests__/plugins/requireMatchingLabel.test.ts @@ -12,6 +12,7 @@ import { handleIssues } from '../../src/issues/handleIssues' import { platform, setPlatform } from '../../src/platform' import { applicableRules, checkRequiredLabels, enforceRequiredLabels, evaluate, maxGracePeriodMs, parseDuration, requireMatchingLabel } from '../../src/plugins/requireMatchingLabel' import { handlePullReq } from '../../src/pullReq/handlePullReq' +import { mergeProwConfig } from '../../src/utils/config' import * as labeling from '../../src/utils/labeling' import { newOctokit } from '../../src/utils/octokit' import * as sleepModule from '../../src/utils/sleep' @@ -108,7 +109,7 @@ function commentEvent(body: string, overrides: Record = {}) { } function configWith(...rules: RequireMatchingLabel[]): ProwConfig { - return { labels: {}, require_matching_label: rules, tide: {}, hold: {}, blunderbuss: {}, sources: [] } + return { ...mergeProwConfig({}, {}), require_matching_label: rules, sources: [] } } const botComment = { id: 11, body: `Please add a kind label.\n\n${marker}`, user: { login: 'github-actions[bot]', type: 'Bot' } } diff --git a/__tests__/plugins/tide.test.ts b/__tests__/plugins/tide.test.ts index 4f853c85..0b905672 100644 --- a/__tests__/plugins/tide.test.ts +++ b/__tests__/plugins/tide.test.ts @@ -1,3 +1,4 @@ +import type { MockInstance } from 'vitest' import { Buffer } from 'node:buffer' import * as core from '@actions/core' import { http } from 'msw' @@ -61,7 +62,7 @@ function observeMerge(status = 200, body: unknown = { merged: true }) { return observe } -let sleep: ReturnType +let sleep: MockInstance let octokit: ReturnType let context: utils.MockContext diff --git a/__tests__/plugins/tideGuards.test.ts b/__tests__/plugins/tideGuards.test.ts index 378c90bb..13f11a73 100644 --- a/__tests__/plugins/tideGuards.test.ts +++ b/__tests__/plugins/tideGuards.test.ts @@ -1,3 +1,4 @@ +import type { MockInstance } from 'vitest' import * as core from '@actions/core' import { http } from 'msw' import { setupServer } from 'msw/node' @@ -81,7 +82,7 @@ function serveGraphql(queue: utils.MergeQueueFixture = {}) { return calls } -let sleep: ReturnType +let sleep: MockInstance let octokit: ReturnType let context: utils.MockContext diff --git a/__tests__/setup.ts b/__tests__/setup.ts index 1b22ebe2..143b7340 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -16,7 +16,7 @@ vi.mock('@actions/core', { spy: true }) // through the same chai method. chai.use((_chai, utils) => { for (const method of ['throw', 'throws', 'Throw']) { - utils.overwriteMethod(_chai.Assertion.prototype, method, _super => function (this: object, ...args: unknown[]) { + utils.overwriteMethod(_chai.Assertion.prototype, method, (_super: (...args: unknown[]) => unknown) => function (this: object, ...args: unknown[]) { const object: unknown = utils.flag(this, 'object') const negate = Boolean(utils.flag(this, 'negate')) const promise = utils.flag(this, 'promise') diff --git a/__tests__/testUtils.ts b/__tests__/testUtils.ts index 9a78b0b3..ae6b0811 100644 --- a/__tests__/testUtils.ts +++ b/__tests__/testUtils.ts @@ -1,5 +1,6 @@ -import type { SetupServerApi } from 'msw/node' +import type { SetupServer } from 'msw/node' import type { Context } from '../src/utils/context' +import { Buffer } from 'node:buffer' import { http } from 'msw' import { afterEach, beforeEach, expect } from 'vitest' @@ -33,7 +34,7 @@ export const failOnUnhandledRequest = { onUnhandledRequest: 'error', onUnhandled * * @param server - the file's msw server */ -export function failTestOnUnhandledRequests(server: SetupServerApi) { +export function failTestOnUnhandledRequests(server: SetupServer) { const unhandled: string[] = [] let allowed = false const record = ({ request }: { request: Request }) => { @@ -104,6 +105,26 @@ export function noOrgOrRepoConfigExcept(...except: string[]) { .map(source => http.get(contentsUrl(source), mockResponse(404, { message: 'Not Found' }))) } +/** + * configFoundAt serves an empty configuration file at the given probe and 404 + * at every other one, so the tier that owns it ends with that single request. + * + * loadProwConfig probes the organization and repository tiers at once; when the + * tier a test is exercising rejects, the other tier's chain of probes keeps + * going after the test's await returns, and a probe started between tests is + * reported against whichever test runs next. Ending the other tier on its first + * probe leaves only a request that is already in flight when the rejection lands. + * + * @param source - `owner/repo:path` or a repo-relative path of the probe that exists + * @param others - further probes the caller serves itself + */ +export function configFoundAt(source: string, ...others: string[]) { + return [ + http.get(contentsUrl(source), mockResponse(200, { type: 'file', encoding: 'base64', content: Buffer.from('{}\n').toString('base64') })), + ...noOrgOrRepoConfigExcept(source, ...others), + ] +} + /** * repoHasLabels serves `GET /repos/Codertocat/Hello-World/labels` with the * given labels on a single page (any `per_page`/`page` query is ignored), so diff --git a/__tests__/utils/auth.test.ts b/__tests__/utils/auth.test.ts index 3f5c5020..46fc1e6c 100644 --- a/__tests__/utils/auth.test.ts +++ b/__tests__/utils/auth.test.ts @@ -513,16 +513,16 @@ describe('authorization policies', () => { }) it.each([ - ['a failed read', [http.get(utils.contentsUrl('.github/prow.yaml'), utils.mockResponse(500, { message: 'boom' })), ...utils.noOrgOrRepoConfigExcept('.github/prow.yaml')]], - ['malformed yaml', prowYaml('authorization:\n close: everyone\n')], - ])('refuses with a warning, and no further call, when the configuration cannot be loaded: %s', async (_, handlers) => { + ['a failed read', http.get(utils.contentsUrl('.github/prow.yaml'), utils.mockResponse(500, { message: 'boom' }))], + ['malformed yaml', prowYaml('authorization:\n close: everyone\n')[0]], + ])('refuses with a warning, and no further call, when the configuration cannot be loaded: %s', async (_, repoFile) => { const warning = vi.spyOn(core, 'warning').mockImplementation(() => {}) - server.use(...handlers) + // the organization tier is probed alongside the failing repository tier; end it on its first probe so + // the only sibling request is in flight, and drained by afterEach, when the rejection lands + server.use(repoFile, ...utils.configFoundAt('Codertocat/.project:prow.yaml', '.github/prow.yaml')) await expect(closePolicyAllows(octokit, context, 'Alice')).resolves.toBe(false) expect(warning).toHaveBeenCalledWith(expect.stringContaining('authorization: could not load prow config: ')) - // the organization tier keeps probing after the repository tier failed; let it finish inside this test - await vi.waitFor(() => expect(calls).toContain('GET /repos/Codertocat/.github/contents/prow.yaml')) expect(calls.filter(call => !call.includes('/contents/'))).toEqual([]) }) @@ -583,7 +583,8 @@ describe('authorization policies', () => { it('falls back to the members message, with a warning, when the configuration cannot be loaded', async () => { const warning = vi.spyOn(core, 'warning').mockImplementation(() => {}) - server.use(rootOwners(), ...membership(), ...prowYaml('authorization:\n review: anyone\n users: [alice]\n')) + // end the organization tier on its first probe: its chain would otherwise outlive this test (see configFoundAt) + server.use(rootOwners(), ...membership(), prowYaml('authorization:\n review: anyone\n users: [alice]\n')[0], ...utils.configFoundAt('Codertocat/.project:prow.yaml', '.github/prow.yaml')) await expect(assertAuthorizedByOwnersOrMembership(octokit, context, 'reviewers', 'Alice')) .rejects diff --git a/__tests__/utils/configTypeGuards.test.ts b/__tests__/utils/configTypeGuards.test.ts index b92c165c..f5057572 100644 --- a/__tests__/utils/configTypeGuards.test.ts +++ b/__tests__/utils/configTypeGuards.test.ts @@ -19,7 +19,7 @@ describe('parseProwConfig type guards', () => { expect(() => parseProwConfig('x', 'kind:\n - { name: bug, color: 123456 }\n')).toThrow(malformed) expect(() => parseProwConfig('x', 'kind:\n - { name: bug, color: 12 }\n')).toThrow(malformed) - expect(parseProwConfig('x', 'kind:\n - { name: bug, color: "123456" }\n').labels.kind.definitions).toEqual([ + expect(parseProwConfig('x', 'kind:\n - { name: bug, color: "123456" }\n').labels?.kind?.definitions).toEqual([ { name: 'bug', color: '123456' }, ]) }) diff --git a/__tests__/utils/mergeQueueGuards.test.ts b/__tests__/utils/mergeQueueGuards.test.ts index 8c515deb..d0b186e1 100644 --- a/__tests__/utils/mergeQueueGuards.test.ts +++ b/__tests__/utils/mergeQueueGuards.test.ts @@ -2,7 +2,7 @@ import type { Octokit } from '@octokit/rest' import { afterEach, describe, expect, it, vi } from 'vitest' -import { enqueue, enqueuedByBot, queueState } from '../../src/utils/mergeQueue' +import { enqueue, enqueuedByBot } from '../../src/utils/mergeQueue' import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json' import * as utils from '../testUtils' @@ -23,9 +23,12 @@ afterEach(() => vi.resetModules()) describe('errorMessage', () => { it('reports a null rejection of the state query as "null" instead of throwing', async () => { - const core = await import('@actions/core') + // all fresh: after a sibling's resetModules the static queueState would warn through another core + // instance, and the fresh module graph's platform slot is empty until this test fills it + const [core, fresh, freshPlatform, adapter] = await Promise.all([import('@actions/core'), import('../../src/utils/mergeQueue'), import('../../src/platform'), import('../../src/adapters/actions')]) + freshPlatform.setPlatform(adapter.actionsPlatform()) - await expect(queueState(rejectingWith(null), context, 7)).resolves.toBeUndefined() + await expect(fresh.queueState(rejectingWith(null), context, 7)).resolves.toBeUndefined() expect(core.warning).toHaveBeenCalledWith('could not read the merge queue state of pr #7; falling back to a direct merge: null') }) diff --git a/__tests__/utils/ownersGuards.test.ts b/__tests__/utils/ownersGuards.test.ts index b60077ad..0d7a6e9c 100644 --- a/__tests__/utils/ownersGuards.test.ts +++ b/__tests__/utils/ownersGuards.test.ts @@ -141,7 +141,7 @@ describe('branchHasOwners', () => { utils.newRun() }) - it.each([ + it.each<[string | null, string]>([ ['boom', 'a string'], [null, 'null'], ])('reports a tree rejection of %j (%s) as a listing error, not as a missing tree', async (reason) => { diff --git a/__tests__/utils/utilsErrorPaths.test.ts b/__tests__/utils/utilsErrorPaths.test.ts index d6128303..5faf40da 100644 --- a/__tests__/utils/utilsErrorPaths.test.ts +++ b/__tests__/utils/utilsErrorPaths.test.ts @@ -115,12 +115,14 @@ describe('utils error paths', () => { describe('loadProwConfig explicit sources', () => { const explicitUrl = `${utils.api}/repos/cncf/prow-config/contents/prow.yaml` + // the repository tier is probed alongside the explicit source; every rejecting case below ends it on its + // first probe so that no probe of its chain starts after the test has returned (see configFoundAt) it('wraps a non-404 failure reading the explicit repo file', async () => { process.env.INPUT_CONFIG = 'cncf/prow-config:prow.yaml' server.use( http.get(explicitUrl, utils.mockResponse(500, { message: 'boom' })), - ...utils.noOrgOrRepoConfigExcept(), + ...utils.configFoundAt('.github/prow.yaml'), ) await expect(loadProwConfig(octokit, context)).rejects.toThrow( @@ -132,7 +134,7 @@ describe('utils error paths', () => { process.env.INPUT_CONFIG = 'cncf/prow-config:prow.yaml' server.use( http.get(explicitUrl, utils.mockResponse(200, [{ ...labelFileContents, name: 'a.yaml' }])), - ...utils.noOrgOrRepoConfigExcept(), + ...utils.configFoundAt('.github/prow.yaml'), ) await expect(loadProwConfig(octokit, context)).rejects.toThrow( @@ -144,7 +146,7 @@ describe('utils error paths', () => { process.env.INPUT_CONFIG = 'cncf/prow-config:prow.yaml' server.use( http.get(explicitUrl, utils.mockResponse(200, { ...labelFileContents, content: undefined, encoding: 'base64' })), - ...utils.noOrgOrRepoConfigExcept(), + ...utils.configFoundAt('.github/prow.yaml'), ) await expect(loadProwConfig(octokit, context)).rejects.toThrow('prow.yaml is not a file') @@ -154,7 +156,7 @@ describe('utils error paths', () => { process.env.INPUT_CONFIG = 'https://config.example.com/prow.yaml' server.use( http.get('https://config.example.com/prow.yaml', () => HttpResponse.error()), - ...utils.noOrgOrRepoConfigExcept(), + ...utils.configFoundAt('.github/prow.yaml'), ) await expect(loadProwConfig(octokit, context)).rejects.toThrow( diff --git a/__tests__/worker/worker.test.ts b/__tests__/worker/worker.test.ts index 5537b0ce..e1560f67 100644 --- a/__tests__/worker/worker.test.ts +++ b/__tests__/worker/worker.test.ts @@ -88,7 +88,7 @@ describe('the Worker', () => { return JSON.parse((await log.get('seen')) ?? '[]') as Seen[] } - async function deliver(eventName: string, payload: unknown, id = crypto.randomUUID(), useSecret = secret) { + async function deliver(eventName: string, payload: unknown, id: string = crypto.randomUUID(), useSecret = secret) { const body = JSON.stringify(payload) return mf.dispatchFetch('https://prow.test/webhook', { method: 'POST', diff --git a/docs/contributing.md b/docs/contributing.md index 7373a0e6..14174d57 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -18,6 +18,14 @@ npm run all # build, lint, pack the dist/ bundle, and test Note that `npm run all` runs `lint:fix` first, which rewrites files in place. +`npm run build` compiles `src/` only (`tsconfig.json` excludes `__tests__/`), +and Vitest strips types without checking them, so a test whose fixture has +drifted from the type it claims to be still runs. `npm run typecheck` +(`tsc -p tsconfig.test.json`) type-checks `src/` and `__tests__/` together +without emitting; CI runs it in `build-test`, so run it after changing an +exported type or a test fixture. `src/server/worker.ts` is excluded there and +checked by `npm run build` (`tsconfig.worker.json`) with the Cloudflare types. + The action runs from the committed `dist/index.js` (an `ncc` bundle of `src/`). Any change to `src/` must be followed by `npm run pack`, and the resulting `dist/index.js` committed alongside it; CI fails if `dist/` is out of date. @@ -50,6 +58,7 @@ stay in step with `action.yml` and the label catalogue. | `npm test` | The whole Vitest suite | | `npm run test:coverage` | The suite with v8 coverage; CI enforces the thresholds in `vitest.config.mjs` (lines 99, branches 99, functions 99, statements 99, a floor set just under what `main` reaches) | | `npx vitest run __tests__/bundle` | Only the bundle acceptance harness | +| `npm run test:shuffle` | The whole suite with the tests of every file in a random order (`--sequence.shuffle`; pass `--sequence.seed=` to repeat an order); see "Test order" below | | `npm run test:coverage:e2e` | Only the bundle acceptance harness, run against a source-mapped bundle built from `__tests__/bundle/coverageEntry/` into `.coverage-bundle/` so its hits are reported against `src/`, written to `coverage-e2e/` so it never overwrites the unit run's `coverage/` (ncc's map is line-only, so the build also emits tsc's per-file maps into `.coverage-bundle/tsc/` and `coverageEntry/remap.mjs` composes their columns into it; without that, inner lines of a multi-line expression that tsc flattens onto one JS line report 0 hits). This is end-to-end-only coverage and is deliberately reported separately from `test:coverage`: the unit run already reaches every line, so a union would say nothing about what the bundle exercises, and the two runs' statement and branch maps come from different transforms (vite vs. tsc+webpack) and do not combine. Fails below 99 % lines / 99 % branches / 99 % functions / 99 % statements (`vitest.e2e-coverage.config.mjs`), a floor set just under what `main` reaches so a scenario that stops driving a `src/` path is caught | Unit tests under `__tests__/` import `src/` directly and mock the GitHub API @@ -111,6 +120,38 @@ export async function gate(a: boolean, b: boolean): Promise { In the same reproduction an explicit `else { … }` block, which owns a V8 range of its own, is counted correctly. +### Test order + +Vitest runs the tests of a file in source order, so a test that leaves state +behind is only noticed by the tests that happen to follow it. `npm run +test:shuffle` runs every file's tests in a random order (the seed is printed +with the failure; `--sequence.seed=` repeats it). The three ways a test has +leaked into its neighbour here, and how each file now avoids them: + +- **A mutated fixture.** The JSON fixtures are imported once per file, so + `event.comment.body = '/assign @x'` is seen by every later test of the file; + the test that relied on the fixture's own body then dispatched a different + command. Either `structuredClone` the fixture before changing it (the cron + tests' `lgtmPr`) or reset the field in a `beforeEach`. +- **A probe chain outliving its test.** `loadProwConfig` probes the + organization and repository tiers under `Promise.all`; when the tier a test + exercises rejects, the other tier's probes keep going after the test's + `await` has returned, and a probe that starts between two tests is reported — + by `failTestOnUnhandledRequests`, or by a `request:start` listener — against + the next one. Waiting for the in-flight count to reach zero does not help, + because the chain is idle between two of its probes. `utils.configFoundAt` + serves an empty configuration at the other tier's first probe, so the only + sibling request is already in flight when the rejection lands. +- **A memo that outlives the test.** Everything a handler memoizes for an + event — configuration, repository labels, OWNERS trees, warnings already + shown — lives in the platform's `RunState`, and `__tests__/setup.ts` + installs a fresh platform before every test, so a test inherits nothing the + previous one loaded. A test that swaps in its own platform mid-test (or a + `describe` that needs an empty run between its own tests) calls + `utils.newRun()`. A file that uses `vi.resetModules()` must import + `@actions/core` and the module under test fresh together, or the assertion + spies on a different `core` instance than the one the module warns through. + ### Mutation checks Line coverage says a test *reached* a statement, not that it would notice the diff --git a/package.json b/package.json index c0efa7ee..8ad12085 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "scripts": { "build": "tsc && tsc -p tsconfig.core.json && tsc -p tsconfig.worker.json", + "typecheck": "tsc -p tsconfig.test.json", "lint": "eslint", "lint:fix": "eslint --fix", "pack": "ncc build && npm run pack:server && npm run pack:worker", @@ -33,6 +34,7 @@ "test:coverage": "vitest run --coverage", "pack:coverage": "ncc build __tests__/bundle/coverageEntry/main.ts -o .coverage-bundle --source-map --no-source-map-register && tsc -p __tests__/bundle/coverageEntry/tsconfig.json --outDir .coverage-bundle/tsc && node __tests__/bundle/coverageEntry/remap.mjs", "test:coverage:e2e": "npm run pack:coverage && vitest run --coverage --config vitest.e2e-coverage.config.mjs __tests__/bundle", + "test:shuffle": "vitest run --sequence.shuffle", "test:mutation": "stryker run", "test:watch": "vitest", "all": "npm run build && npm run lint:fix && npm run lint && npm run pack && npm test", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..f332be58 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"], + "exclude": ["node_modules", "__tests__/bundle/coverageEntry", "src/server/worker.ts", "src/server/worker-env.d.ts"] +}