From 0b409e2dba39b9fabb4daa3338ab4c9f16535871 Mon Sep 17 00:00:00 2001 From: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:05:40 -0400 Subject: [PATCH] test(bundle): drive getErrorDetails' status-less and message-less object folds through dist/index.js npm run test:coverage:e2e lists src/utils/auth.ts:15-17 as zero-hit: the 'status' in error and typeof error.message === 'string' else-arms of getErrorDetails. @octokit/request wraps every response and fetch failure in a RequestError, which carries both fields, so no fake-GitHub response can reach them. Add objectRejectionPreload.cjs, which hands the bundle one Response whose status getter throws the plain object named by OBJECT_REJECT_VALUE (the one read @octokit/request makes outside its try/catch), and three /retitle runs through checkCollaborator: a status-less socket-error shape, an object with a status but no string message, and a bare 404. auth.ts drops to the single documented negative-count record at line 220; e2e branches 99.03 -> 99.15. Signed-off-by: quality <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- .../bundle/objectRejectionAuthArms.test.ts | 90 +++++++++++++++++++ __tests__/bundle/objectRejectionPreload.cjs | 42 +++++++++ 2 files changed, 132 insertions(+) create mode 100644 __tests__/bundle/objectRejectionAuthArms.test.ts create mode 100644 __tests__/bundle/objectRejectionPreload.cjs diff --git a/__tests__/bundle/objectRejectionAuthArms.test.ts b/__tests__/bundle/objectRejectionAuthArms.test.ts new file mode 100644 index 00000000..05658ee9 --- /dev/null +++ b/__tests__/bundle/objectRejectionAuthArms.test.ts @@ -0,0 +1,90 @@ +import type { FakeGithub } from './fakeGithub' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { start } from './fakeGithub' +import { comment, helpersFor, repo, token } from './helpers' +import { runBundle } from './runBundle' + +vi.setConfig({ testTimeout: 30_000 }) + +const preload = path.resolve(__dirname, 'objectRejectionPreload.cjs') + +// getErrorDetails (src/utils/auth.ts) folds an object rejection's fields: `'status' in error ? error.status : 'unknown'` +// and `'message' in error && typeof error.message === 'string' ? error.message : String(error)`. Octokit wraps every +// fetch rejection in a RequestError, which always carries both, so neither else-arm runs through the bundle unless a +// client call rejects with a bare object. The preload hands the bundle one Response whose `status` getter throws the +// JSON object the test names, the one read @octokit/request makes outside its try/catch. Each fold must still take +// the "unexpected error" path: warn with the folded details, deny the write, and leave no unhandled rejection. +describe('dist/index.js when a collaborator check rejects with a plain object', () => { + let gh: FakeGithub + const { calls } = helpersFor(() => gh) + + const collaboratorPath = `${repo}/collaborators/Codertocat` + const target = `GET ${collaboratorPath}` + + beforeAll(async () => { + gh = await start() + }) + afterEach(() => gh.reset()) + afterAll(() => gh.close()) + + function env(value: Record) { + return { NODE_OPTIONS: `--require "${preload}"`, OBJECT_REJECT: target, OBJECT_REJECT_VALUE: JSON.stringify(value) } + } + + async function retitle(value: Record) { + gh.route('GET', collaboratorPath, { status: 204 }) + gh.route('PATCH', `${repo}/issues/1`, { status: 200, body: {} }) + + return runBundle({ + eventName: 'issue_comment', + payload: comment('/retitle A better title'), + inputs: { ...token, 'prow-commands': '/retitle' }, + apiUrl: gh.url, + env: env(value), + }) + } + + function warnings(stdout: string) { + return stdout.split(/\r?\n/).filter(line => line.startsWith('::warning::')) + } + + it('a status-less object with a message (a socket error shape): status folds to "unknown", the message is kept verbatim, and the retitle is denied', async () => { + const result = await retitle({ code: 'ECONNRESET', message: 'socket hang up' }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(warnings(result.stdout)).toEqual([ + '::warning::encountered unexpected error checking collaborator status: status=unknown, message=socket hang up', + ]) + expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/) + // the faulted read reached the fake and was recorded like any other; nothing was written + expect(calls()).toEqual([target]) + expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([]) + }) + + it('an object with a status but no string message: the status is kept, the message falls back to String(error), and the retitle is denied', async () => { + const result = await retitle({ status: 502, code: 'EAI_AGAIN' }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(warnings(result.stdout)).toEqual([ + '::warning::encountered unexpected error checking collaborator status: status=502, message=[object Object]', + ]) + expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/) + expect(calls()).toEqual([target]) + expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([]) + }) + + it('a 404 carried by a bare object is still read as "not a collaborator" and denies the retitle quietly', async () => { + const result = await retitle({ status: 404, message: 'Not Found' }) + + expect(result.status, result.stdout).toBe(0) + expect(result.errors).toEqual([]) + expect(warnings(result.stdout)).toEqual([]) + expect(result.stderr).not.toMatch(/Unhandled|unhandledRejection/) + expect(calls()).toEqual([target]) + expect(gh.requestsMatching('PATCH', /\/issues\/1$/)).toEqual([]) + }) +}) diff --git a/__tests__/bundle/objectRejectionPreload.cjs b/__tests__/bundle/objectRejectionPreload.cjs new file mode 100644 index 00000000..5e26ed3b --- /dev/null +++ b/__tests__/bundle/objectRejectionPreload.cjs @@ -0,0 +1,42 @@ +// Preloaded into the bundle child with `NODE_OPTIONS=--require` by the object-rejection e2e test. +// The first request whose `METHOD /path` equals OBJECT_REJECT still reaches the fake GitHub (so it is +// recorded and answered) but the bundle is handed a Response whose `status` getter throws the plain object +// parsed from OBJECT_REJECT_VALUE. `status` is the first thing @octokit/request reads after its fetch +// try/catch, so the object rejects the `octokit.*` call as-is instead of being wrapped in a RequestError: +// the only way an Error-less object (a `status`-less or `message`-less rejection) reaches the bundle's +// `getErrorDetails` folds through the real client. Every other request, and the matching request's second +// occurrence, is untouched. Fails closed: without both variables the preload throws instead of silently +// running the test against the real client. +'use strict' + +const target = process.env.OBJECT_REJECT +if (!target) + throw new Error('objectRejectionPreload: OBJECT_REJECT is unset') +const rawValue = process.env.OBJECT_REJECT_VALUE +if (!rawValue) + throw new Error('objectRejectionPreload: OBJECT_REJECT_VALUE is unset') +const rejection = JSON.parse(rawValue) +if (typeof rejection !== 'object' || rejection === null) + throw new Error('objectRejectionPreload: OBJECT_REJECT_VALUE must be a JSON object') + +const realFetch = globalThis.fetch +let armed = true + +globalThis.fetch = async (input, init) => { + const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase() + const response = await realFetch(input, init) + if (!armed || `${method} ${new URL(href).pathname}` !== target) + return response + armed = false + await response.body?.cancel() + return new Proxy(response, { + get(resp, prop) { + if (prop === 'status') + // a non-Error object is the point: the bundle's status-less / message-less folds are what this preload exists to reach + throw rejection + const value = Reflect.get(resp, prop, resp) + return typeof value === 'function' ? value.bind(resp) : value + }, + }) +}