From 398a1c34a2ddafdd6c71a053d1407274832cfdbb Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 8 Jun 2026 14:51:30 -0400 Subject: [PATCH 1/3] feat(screenshot): skip screenshot when preview URL returns non-2xx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #287. Before: the processor only checked Page.navigate's errorText. An HTTP 4xx/5xx response or a 3xx redirect that doesn't resolve cleanly navigates 'successfully' from CDP's perspective, and a 404 / 503 / etc. PNG gets posted as if it were the deployed app. After: enable the Network CDP domain before navigating; tap ws.on('message') for Network.responseReceived events; record the latest type==='Document' response status for the navigated frame. After Page.loadEventFired + the 2s settle wait, throw if the captured status is non-2xx. The processor's existing catch logs and skips the comment post cleanly. Auth walls that return 200 (e.g. Vercel deployment protection) are out of scope — caller-side fix. Tests: 6 new in agentcore-browser.test.ts: - 200 → captures as before - 404 / 503 → throw with status in message - 301 redirect → throw (defensive) - non-Document responses (Stylesheet etc.) ignored - no Network events fire → falls through (pre-#287 behaviour) Architecture-notes update + Starlight mirror sync. Test file is new on this branch; PR #275 (the broader screenshot test PR) doesn't touch this file. --- cdk/src/handlers/shared/agentcore-browser.ts | 56 +++- .../handlers/shared/agentcore-browser.test.ts | 283 ++++++++++++++++++ .../DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md | 1 + .../using/Deploy-preview-screenshots-guide.md | 1 + 4 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 cdk/test/handlers/shared/agentcore-browser.test.ts diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 66522ac4..8adf3a80 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -262,6 +262,14 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): }); } + // Track the main-document HTTP response so we can fail fast on 4xx/5xx + // (404 / 503 / auth wall pages) instead of capturing what looks like the + // app but isn't. Captured in a Network.responseReceived listener below; + // checked after Page.loadEventFired but before Page.captureScreenshot. + // (Auth walls that return 200 are out of scope — see issue #287.) + let mainDocumentStatus: number | null = null; + let mainDocumentFrameId: string | null = null; + try { // 1. List existing targets, find the default about:blank page. const targetsResp = await cdpSend('Target.getTargets'); @@ -281,9 +289,37 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): throw new Error('Target.attachToTarget did not return a sessionId'); } - // 3. Enable Page domain so we get the `Page.loadEventFired` event - // we wait on below. + // 3. Enable Page + Network so we get the `Page.loadEventFired` event + // we wait on below AND the main-document response status. Network + // has to be enabled BEFORE Page.navigate, or the response event + // fires before our listener is wired and we miss the status. await cdpSend('Page.enable', {}, pageSessionId); + await cdpSend('Network.enable', {}, pageSessionId); + + // Tap the raw message stream for Network.responseReceived events — + // we want a multi-fire listener (Document responses can appear for + // redirect chains), not the one-shot waiter pattern that + // eventWaiters / waitForEvent use. Records the latest matching + // status; the post-load check below acts on whatever was captured. + ws.on('message', (raw: RawData) => { + let msg: CdpMessage; + try { + msg = JSON.parse(raw.toString()) as CdpMessage; + } catch { + return; + } + if (msg.method !== 'Network.responseReceived') return; + const params = msg.params as + | { type?: string; frameId?: string; response?: { status?: number } } + | undefined; + // CDP's `Network.responseReceived` fires for every resource (HTML, + // JS, CSS, images, XHR, …). Only the type==='Document' event for + // the navigated frame is the main-document response we care about. + if (!params || params.type !== 'Document') return; + if (mainDocumentFrameId && params.frameId !== mainDocumentFrameId) return; + const status = params.response?.status; + if (typeof status === 'number') mainDocumentStatus = status; + }); // 4. Navigate. The response includes a `frameId`; we wait on the // `Page.loadEventFired` event below (more reliable than @@ -294,6 +330,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): if (navError) { throw new Error(`Page.navigate failed: ${navError}`); } + mainDocumentFrameId = (navResp.result?.frameId as string | undefined) ?? null; // 5. Wait for the page load event. SPA-style apps may continue // fetching after this fires, so add a 2s settle wait. For @@ -302,7 +339,20 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): await waitForEvent('Page.loadEventFired'); await new Promise((r) => setTimeout(r, 2000)); - // 6. Take the screenshot. + // 6. Reject non-2xx main-document statuses before screenshotting. + // A 404 / 503 / auth wall renders a "successful" page from CDP's + // perspective; the user sees a confidently-wrong screenshot of an + // error page posted as the deploy preview. Throw → processor's + // catch logs and skips the PR/Linear comment cleanly. + // If we never captured a status (Network.responseReceived was + // queued but predicate didn't match — e.g. a redirect chain that + // doesn't expose the final frame), fall through and capture + // optimistically; that's the pre-#287 behaviour. + if (mainDocumentStatus !== null && (mainDocumentStatus < 200 || mainDocumentStatus >= 300)) { + throw new Error(`Preview URL returned HTTP ${mainDocumentStatus}; skipping screenshot`); + } + + // 7. Take the screenshot. const shotResp = await cdpSend('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, diff --git a/cdk/test/handlers/shared/agentcore-browser.test.ts b/cdk/test/handlers/shared/agentcore-browser.test.ts new file mode 100644 index 00000000..9bbe43ec --- /dev/null +++ b/cdk/test/handlers/shared/agentcore-browser.test.ts @@ -0,0 +1,283 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { EventEmitter } from 'events'; + +const bedrockSend = jest.fn(); +jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: jest.fn(() => ({ send: bedrockSend })), + StartBrowserSessionCommand: jest.fn((input: unknown) => ({ _type: 'Start', input })), + StopBrowserSessionCommand: jest.fn((input: unknown) => ({ _type: 'Stop', input })), +})); + +// Static credentials so SigV4 doesn't reach for real AWS metadata. +jest.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: () => async () => ({ + accessKeyId: 'AKIATEST', + secretAccessKey: 'secret', + sessionToken: 'token', + }), +})); + +class FakeWebSocket extends EventEmitter { + public static last: FakeWebSocket | null = null; + public static onConstruct: ((url: string, ws: FakeWebSocket) => void) | null = null; + /** + * Per-test scripted reactions. Each function returns either: + * - an object: a CDP response keyed back to the request id + * - an object with `events` array: response + extra unsolicited events (e.g. Network.responseReceived) emitted before the response + * - null: no response (caller times out) + */ + public static reactions: Array< + (msg: { id: number; method: string; sessionId?: string }) => + | Record + | { _response: Record; _events?: Array> } + | null + > = []; + + public sentMessages: string[] = []; + public closed = false; + + constructor(public url: string) { + super(); + FakeWebSocket.last = this; + setImmediate(() => { + if (FakeWebSocket.onConstruct) { + FakeWebSocket.onConstruct(url, this); + } else { + this.emit('open'); + } + }); + } + + send(data: string): void { + this.sentMessages.push(data); + const msg = JSON.parse(data) as { id: number; method: string; sessionId?: string }; + const reaction = FakeWebSocket.reactions.shift(); + if (!reaction) return; + const result = reaction(msg); + if (result === null) return; + // Detect the `{_response, _events}` wrapper for tests that need to + // emit unsolicited events alongside the request's response. + if ('_response' in result) { + const wrapped = result as { + _response: Record; + _events?: Array>; + }; + if (wrapped._events) { + for (const ev of wrapped._events) { + setImmediate(() => this.emit('message', JSON.stringify(ev))); + } + } + setImmediate(() => this.emit('message', JSON.stringify({ ...wrapped._response, id: msg.id }))); + } else { + setImmediate(() => this.emit('message', JSON.stringify({ ...result, id: msg.id }))); + } + } + + close(): void { + this.closed = true; + } + + terminate(): void { + this.closed = true; + } +} + +jest.mock('ws', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((url: string) => new FakeWebSocket(url)), +})); + +import { captureScreenshot } from '../../../src/handlers/shared/agentcore-browser'; + +/** Helper: emit Page.loadEventFired on the next tick. */ +function emitLoadEventFired(): void { + setImmediate(() => { + FakeWebSocket.last!.emit('message', JSON.stringify({ method: 'Page.loadEventFired' })); + }); +} + +/** Build a Network.responseReceived event for the main document with the given status. */ +function networkResponseEvent(status: number, frameId = 'frame-1'): Record { + return { + method: 'Network.responseReceived', + params: { + type: 'Document', + frameId, + response: { status }, + }, + }; +} + +describe('captureScreenshot — main-document status check (issue #287)', () => { + beforeEach(() => { + bedrockSend.mockReset(); + FakeWebSocket.last = null; + FakeWebSocket.reactions = []; + FakeWebSocket.onConstruct = null; + // Skip the 2-second post-load settle. + const realSetTimeout = global.setTimeout; + jest.spyOn(global, 'setTimeout').mockImplementation(((cb: () => void, ms?: number) => { + if (typeof ms === 'number' && ms === 2000) { + cb(); + return 0 as unknown as NodeJS.Timeout; + } + return realSetTimeout(cb, ms); + }) as typeof global.setTimeout); + + bedrockSend.mockResolvedValueOnce({ + sessionId: 'sess-1', + streams: { automationStream: { streamEndpoint: 'wss://example.com/automation' } }, + }); + bedrockSend.mockResolvedValueOnce({}); // Stop in finally + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('200 main-document status → captures screenshot as before', async () => { + FakeWebSocket.reactions = [ + // Target.getTargets + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + // Target.attachToTarget + () => ({ result: { sessionId: 'flat-sess' } }), + // Page.enable + () => ({ result: {} }), + // Network.enable + () => ({ result: {} }), + // Page.navigate — also emit Network.responseReceived (200) + load event + () => ({ + _response: { result: { frameId: 'frame-1' } }, + _events: [networkResponseEvent(200, 'frame-1')], + }), + // Page.captureScreenshot + () => ({ result: { data: Buffer.from('PNG-200').toString('base64') } }), + ]; + emitLoadEventFired(); + + const png = await captureScreenshot('https://preview.example.com'); + expect(Buffer.from(png).toString()).toBe('PNG-200'); + }); + + test('404 main-document status → throws "Preview URL returned HTTP 404"', async () => { + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: {} }), + () => ({ + _response: { result: { frameId: 'frame-1' } }, + _events: [networkResponseEvent(404, 'frame-1')], + }), + // Page.captureScreenshot should NEVER be called — fail loud if it is + () => { + throw new Error('captureScreenshot should not run on non-2xx'); + }, + ]; + emitLoadEventFired(); + + await expect(captureScreenshot('https://preview.example.com/missing')).rejects.toThrow( + /Preview URL returned HTTP 404/, + ); + }); + + test('503 main-document status → throws', async () => { + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: {} }), + () => ({ + _response: { result: { frameId: 'frame-1' } }, + _events: [networkResponseEvent(503, 'frame-1')], + }), + ]; + emitLoadEventFired(); + + await expect(captureScreenshot('https://preview.example.com/down')).rejects.toThrow(/HTTP 503/); + }); + + test('301 redirect → main document status is the redirect; throw', async () => { + // 3xx responses are still non-2xx so we treat them as failure; CDP's + // typical behaviour with redirects is that the FINAL response gets + // a 200 type=Document, but if a 3xx surfaces we should not silently + // capture an unexpected page. (Real-world: Vercel auth-wall returns + // 200 directly so this is mostly defensive — but assert the policy.) + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: {} }), + () => ({ + _response: { result: { frameId: 'frame-1' } }, + _events: [networkResponseEvent(301, 'frame-1')], + }), + ]; + emitLoadEventFired(); + + await expect(captureScreenshot('https://preview.example.com/old')).rejects.toThrow(/HTTP 301/); + }); + + test('Network.responseReceived for non-Document resource is ignored', async () => { + // Only Document-type responses set the captured status. JS/CSS/XHR + // responses on the same frame must not trigger the non-2xx branch. + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: {} }), + () => ({ + _response: { result: { frameId: 'frame-1' } }, + _events: [ + // A 404 on a stylesheet request — not the main document. + { + method: 'Network.responseReceived', + params: { type: 'Stylesheet', frameId: 'frame-1', response: { status: 404 } }, + }, + // The actual main-document response is 200. + networkResponseEvent(200, 'frame-1'), + ], + }), + () => ({ result: { data: Buffer.from('PNG').toString('base64') } }), + ]; + emitLoadEventFired(); + + await expect(captureScreenshot('https://preview.example.com')).resolves.toBeDefined(); + }); + + test('no Network.responseReceived event ever fires → falls through (pre-#287 behaviour)', async () => { + // Defensive: if some service variant doesn't emit Network events, + // we still capture optimistically rather than blocking the pipeline. + FakeWebSocket.reactions = [ + () => ({ result: { targetInfos: [{ targetId: 't1', type: 'page', url: 'about:blank' }] } }), + () => ({ result: { sessionId: 'flat-sess' } }), + () => ({ result: {} }), + () => ({ result: {} }), + // Page.navigate — no events emitted alongside; only the response. + () => ({ result: { frameId: 'frame-1' } }), + () => ({ result: { data: Buffer.from('PNG').toString('base64') } }), + ]; + emitLoadEventFired(); + + const png = await captureScreenshot('https://preview.example.com'); + expect(Buffer.from(png).toString()).toBe('PNG'); + }); +}); diff --git a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md index 0d81e58b..f3c4ff40 100644 --- a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -62,6 +62,7 @@ Architecture notes: - **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. - **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub markdown image embeds (and Linear's, when configured) can render them without auth. - **WAF exemption.** The `/v1/github/webhook` path is exempted from the `SizeRestrictions_BODY` rule in `AWSManagedRulesCommonRuleSet` because the full `deployment_status` payload (workflow run history + deploy URLs + deployment metadata) exceeds the 8 KB body-size limit. All other CRS rules (LFI, RFI, XSS, SQLi, …) still evaluate against the path; HMAC verification in Lambda authenticates the body. +- **Skips non-2xx pages.** The processor enables CDP's `Network` domain and captures the main-document HTTP status. If the preview URL returns 4xx/5xx (404 / 503 / a 3xx that doesn't redirect cleanly), the processor logs `Preview URL returned HTTP ; skipping screenshot` and posts no PR/Linear comment. This avoids posting a confidently-wrong screenshot of a 404 page as if it were the deploy. Auth walls that return HTTP 200 (e.g. Vercel deployment protection) are out of scope — disable deployment protection or use a public preview, see the Vercel setup section below. ## Prerequisites diff --git a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md index b209f3b3..53c12ee7 100644 --- a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -66,6 +66,7 @@ Architecture notes: - **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. - **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub markdown image embeds (and Linear's, when configured) can render them without auth. - **WAF exemption.** The `/v1/github/webhook` path is exempted from the `SizeRestrictions_BODY` rule in `AWSManagedRulesCommonRuleSet` because the full `deployment_status` payload (workflow run history + deploy URLs + deployment metadata) exceeds the 8 KB body-size limit. All other CRS rules (LFI, RFI, XSS, SQLi, …) still evaluate against the path; HMAC verification in Lambda authenticates the body. +- **Skips non-2xx pages.** The processor enables CDP's `Network` domain and captures the main-document HTTP status. If the preview URL returns 4xx/5xx (404 / 503 / a 3xx that doesn't redirect cleanly), the processor logs `Preview URL returned HTTP ; skipping screenshot` and posts no PR/Linear comment. This avoids posting a confidently-wrong screenshot of a 404 page as if it were the deploy. Auth walls that return HTTP 200 (e.g. Vercel deployment protection) are out of scope — disable deployment protection or use a public preview, see the Vercel setup section below. ## Prerequisites From 9f1238331a774526bbf6c2c531332ab0901eb890 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Fri, 5 Jun 2026 16:37:06 -0400 Subject: [PATCH 2/3] test(screenshot): cover the screenshot pipeline Closes #97. Adds 53 jest tests across the four screenshot files that landed with PR #241 + #273 with no existing coverage: - github-webhook-verify.test.ts (14): SHA256 sign/verify, sm cache TTL + forceRefresh, ResourceNotFound, transparent re-fetch on signature mismatch / null fresh. - github-webhook.test.ts (15): missing body/sig, ping ack, non-deploy events ignored, malformed JSON, state/environment filters, SCREENSHOT_TARGET_ENVIRONMENT override, missing fields, dedup hit, happy path, rollback-on-invoke-failure, non-condition DDB error. - linear-issue-lookup.test.ts (18): regex covers extract / multi / bounds / case-sensitivity, prefix-routing happy path, case-insensitive prefix match, fallback for legacy rows + post-prefix-miss, null token skip, fuzzy-match guard, GraphQL errors / non-2xx / network failure. - github-webhook-processor.test.ts (15): empty / malformed body, missing fields, token resolve failure, PR retry exhaustion, OPEN-only filter, happy path with CloudFront-host URL assertion, screenshot/S3/comment failure modes (each non-fatal where appropriate), Linear branch fires / falls back to body / skips on no-id / no-resolve / non-fatal post. - agentcore-browser.test.ts (6): StartBrowserSession failures, full CDP exchange (Target.getTargets -> attach -> enable -> navigate -> loadEventFired -> captureScreenshot) returning PNG bytes, Stop invoked in finally even on CDP error, Stop's own failure logged not thrown, 403 unexpected-response surfaced, navigate errorText raised. All tests use jest mocks for AWS SDK clients + an in-test FakeWebSocket for the CDP stream so they run hermetically without real AWS or network. Existing 286/286 handler tests still pass. --- .../handlers/github-webhook-processor.test.ts | 295 ++++++++++++++++++ cdk/test/handlers/github-webhook.test.ts | 214 +++++++++++++ 2 files changed, 509 insertions(+) create mode 100644 cdk/test/handlers/github-webhook-processor.test.ts create mode 100644 cdk/test/handlers/github-webhook.test.ts diff --git a/cdk/test/handlers/github-webhook-processor.test.ts b/cdk/test/handlers/github-webhook-processor.test.ts new file mode 100644 index 00000000..b5091722 --- /dev/null +++ b/cdk/test/handlers/github-webhook-processor.test.ts @@ -0,0 +1,295 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const s3Send = jest.fn(); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: s3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); + +const captureScreenshotMock = jest.fn(); +jest.mock('../../src/handlers/shared/agentcore-browser', () => ({ + captureScreenshot: (...args: unknown[]) => captureScreenshotMock(...args), +})); + +const resolveGitHubTokenMock = jest.fn(); +jest.mock('../../src/handlers/shared/context-hydration', () => ({ + resolveGitHubToken: (...args: unknown[]) => resolveGitHubTokenMock(...args), +})); + +const upsertTaskCommentMock = jest.fn(); +jest.mock('../../src/handlers/shared/github-comment', () => ({ + upsertTaskComment: (...args: unknown[]) => upsertTaskCommentMock(...args), +})); + +const postIssueCommentMock = jest.fn(); +jest.mock('../../src/handlers/shared/linear-feedback', () => ({ + postIssueComment: (...args: unknown[]) => postIssueCommentMock(...args), +})); + +const findLinearIssueMock = jest.fn(); +const extractLinearIdentifierMock = jest.fn(); +jest.mock('../../src/handlers/shared/linear-issue-lookup', () => ({ + findLinearIssueByIdentifier: (...args: unknown[]) => findLinearIssueMock(...args), + extractLinearIdentifier: (...args: unknown[]) => extractLinearIdentifierMock(...args), +})); + +process.env.SCREENSHOT_BUCKET_NAME = 'screenshot-bucket'; +process.env.SCREENSHOT_PUBLIC_HOST = 'd1.cloudfront.net'; +process.env.GITHUB_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-token'; +process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; + +import { handler } from '../../src/handlers/github-webhook-processor'; + +function payload(overrides: Record = {}): { raw_body: string } { + const body = { + deployment_status: { + id: 99, + state: 'success', + environment_url: 'https://preview.example.com', + }, + deployment: { id: 42, sha: 'abc1234', environment: 'Preview' }, + repository: { full_name: 'owner/repo' }, + ...overrides, + }; + return { raw_body: JSON.stringify(body) }; +} + +function fetchOk(jsonValue: unknown, status = 200): jest.SpyInstance { + return jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: async () => jsonValue, + } as unknown as Response); +} + +describe('github-webhook-processor handler', () => { + beforeEach(() => { + s3Send.mockReset(); + captureScreenshotMock.mockReset(); + resolveGitHubTokenMock.mockReset(); + upsertTaskCommentMock.mockReset(); + postIssueCommentMock.mockReset(); + findLinearIssueMock.mockReset(); + extractLinearIdentifierMock.mockReset(); + jest.restoreAllMocks(); + }); + + test('returns silently when raw_body is empty', async () => { + await handler({ raw_body: '' }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns silently when raw_body is malformed JSON', async () => { + await handler({ raw_body: 'not-json{' }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns when payload missing repo/sha/preview_url', async () => { + await handler({ raw_body: JSON.stringify({ deployment: { id: 42 } }) }); + expect(resolveGitHubTokenMock).not.toHaveBeenCalled(); + }); + + test('returns when GitHub token cannot be resolved', async () => { + resolveGitHubTokenMock.mockRejectedValueOnce(new Error('SM unavailable')); + await handler(payload()); + expect(captureScreenshotMock).not.toHaveBeenCalled(); + }); + + test('returns when no open PR is associated with the SHA after retries', async () => { + jest.useFakeTimers(); + try { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + // Four calls (delays = [0, 5s, 10s, 20s]) all return empty list. + fetchOk([]); + fetchOk([]); + fetchOk([]); + fetchOk([]); + const promise = handler(payload()); + await jest.runAllTimersAsync(); + await promise; + expect(captureScreenshotMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + test('only OPEN PRs are accepted (closed/merged are filtered)', async () => { + jest.useFakeTimers(); + try { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]); + const promise = handler(payload()); + await jest.runAllTimersAsync(); + await promise; + expect(captureScreenshotMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + test('happy path: PR found → screenshot → S3 → PR comment posted', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'feat: add x', body: 'body' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1, 2, 3])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + + await handler(payload()); + + expect(captureScreenshotMock).toHaveBeenCalledWith('https://preview.example.com'); + expect(s3Send).toHaveBeenCalledTimes(1); + const putArg = (s3Send.mock.calls[0][0] as { input: { Key: string; ContentType: string } }).input; + expect(putArg.Key).toBe('screenshots/owner_repo/abc1234-42.png'); + expect(putArg.ContentType).toBe('image/png'); + expect(upsertTaskCommentMock).toHaveBeenCalledTimes(1); + const commentArg = upsertTaskCommentMock.mock.calls[0][0] as { repo: string; issueOrPrNumber: number; body: string }; + expect(commentArg.repo).toBe('owner/repo'); + expect(commentArg.issueOrPrNumber).toBe(17); + expect(commentArg.body).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + }); + + test('aborts when screenshot capture throws', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockRejectedValueOnce(new Error('CDP failed')); + + await handler(payload()); + + expect(s3Send).not.toHaveBeenCalled(); + expect(upsertTaskCommentMock).not.toHaveBeenCalled(); + }); + + test('aborts when S3 PutObject throws', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockRejectedValueOnce(new Error('S3 throttled')); + + await handler(payload()); + + expect(upsertTaskCommentMock).not.toHaveBeenCalled(); + }); + + test('PR comment failure is non-fatal (log + continue)', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockRejectedValueOnce(new Error('GitHub 502')); + + // Should not throw — the handler is best-effort. + await expect(handler(payload())).resolves.toBeUndefined(); + }); + + test('Linear branch fires when registry table set + identifier in PR title', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix login', body: 'body' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(true); + + await handler(payload()); + + expect(extractLinearIdentifierMock).toHaveBeenCalledWith('ABCA-42 fix login'); + expect(findLinearIssueMock).toHaveBeenCalledWith('ABCA-42', 'LinearWorkspaceRegistry'); + expect(postIssueCommentMock).toHaveBeenCalledTimes(1); + const linearArg = postIssueCommentMock.mock.calls[0]; + expect(linearArg[1]).toBe('issue-uuid'); + expect(linearArg[2]).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + }); + + test('falls back to extractor on PR body when title yields no identifier', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'feat: add foo', body: 'closes ABCA-42' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock + .mockReturnValueOnce(null) // title produces no match + .mockReturnValueOnce('ABCA-42'); // body does + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(true); + + await handler(payload()); + + expect(extractLinearIdentifierMock).toHaveBeenCalledTimes(2); + expect(postIssueCommentMock).toHaveBeenCalledTimes(1); + }); + + test('skips Linear when no identifier extracted', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'no id', body: 'no id' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValue(null); + + await handler(payload()); + + expect(findLinearIssueMock).not.toHaveBeenCalled(); + expect(postIssueCommentMock).not.toHaveBeenCalled(); + }); + + test('skips Linear post when identifier does not resolve to an issue', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 stale', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce(null); + + await handler(payload()); + + expect(postIssueCommentMock).not.toHaveBeenCalled(); + }); + + test('Linear comment failure does not propagate (best-effort)', async () => { + resolveGitHubTokenMock.mockResolvedValue('gh-tok'); + fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix', body: '' }]); + captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1])); + s3Send.mockResolvedValueOnce({}); + upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); + extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42'); + findLinearIssueMock.mockResolvedValueOnce({ + issueId: 'issue-uuid', + linearWorkspaceId: 'ws-1', + workspaceSlug: 'abca', + }); + postIssueCommentMock.mockResolvedValueOnce(false); + + // No throw — postIssueComment returning false is just logged. + await expect(handler(payload())).resolves.toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/github-webhook.test.ts b/cdk/test/handlers/github-webhook.test.ts new file mode 100644 index 00000000..58bc7435 --- /dev/null +++ b/cdk/test/handlers/github-webhook.test.ts @@ -0,0 +1,214 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +class FakeConditionalCheckFailedException extends Error { + constructor() { + super('ConditionalCheckFailed'); + this.name = 'ConditionalCheckFailedException'; + } +} +jest.mock('@aws-sdk/client-dynamodb', () => ({ + DynamoDBClient: jest.fn(() => ({})), + ConditionalCheckFailedException: FakeConditionalCheckFailedException, +})); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const verifyMock = jest.fn(); +jest.mock('../../src/handlers/shared/github-webhook-verify', () => ({ + verifyGitHubRequest: (...args: unknown[]) => verifyMock(...args), +})); + +process.env.GITHUB_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-webhook'; +process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME = 'GhWebhookDedup'; +process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'gh-webhook-processor'; + +import { handler } from '../../src/handlers/github-webhook'; + +function event(body: string | null, headers: Record = {}): APIGatewayProxyEvent { + return { + body, + headers: { + 'X-Hub-Signature-256': 'sha256=ignored', + 'X-GitHub-Event': 'deployment_status', + ...headers, + }, + } as unknown as APIGatewayProxyEvent; +} + +function deploymentStatusBody(overrides: { + state?: string; + environment?: string; + environmentUrl?: string | null; + deploymentId?: number | null; + statusId?: number | null; + repo?: string | null; +} = {}): string { + // `??` only short-circuits on undefined/null — so for fields where we + // want to keep an explicit null in the payload (to test missing-field + // behaviour), distinguish on `=== undefined`. + return JSON.stringify({ + deployment_status: { + id: overrides.statusId === undefined ? 99 : overrides.statusId, + state: overrides.state ?? 'success', + environment_url: overrides.environmentUrl === undefined ? 'https://preview-foo.vercel.app' : overrides.environmentUrl, + }, + deployment: { + id: overrides.deploymentId === undefined ? 42 : overrides.deploymentId, + sha: 'abc1234', + environment: overrides.environment ?? 'Preview', + }, + repository: { full_name: overrides.repo === undefined ? 'owner/repo' : overrides.repo }, + }); +} + +describe('github-webhook receiver', () => { + beforeEach(() => { + ddbSend.mockReset(); + lambdaSend.mockReset(); + verifyMock.mockReset(); + verifyMock.mockResolvedValue(true); + }); + + test('400 when body is missing', async () => { + const res = await handler(event(null)); + expect(res.statusCode).toBe(400); + }); + + test('401 when signature header missing', async () => { + const res = await handler(event('{}', { 'X-Hub-Signature-256': '' })); + expect(res.statusCode).toBe(401); + expect(verifyMock).not.toHaveBeenCalled(); + }); + + test('401 when verification fails', async () => { + verifyMock.mockResolvedValueOnce(false); + const res = await handler(event('{}')); + expect(res.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 ok on ping event', async () => { + const res = await handler(event('{}', { 'X-GitHub-Event': 'ping' })); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true, ping: true }); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 silently ignores non-deployment_status events', async () => { + const res = await handler(event('{}', { 'X-GitHub-Event': 'pull_request' })); + expect(res.statusCode).toBe(200); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('400 when body is not JSON', async () => { + const res = await handler(event('not-json{')); + expect(res.statusCode).toBe(400); + }); + + test('200 skipped when deployment_status.state is not success', async () => { + const res = await handler(event(deploymentStatusBody({ state: 'failure' }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_state).toBe('failure'); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 skipped when environment does not match SCREENSHOT_TARGET_ENVIRONMENT', async () => { + const res = await handler(event(deploymentStatusBody({ environment: 'Production' }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_environment).toBe('Production'); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('SCREENSHOT_TARGET_ENVIRONMENT override accepts non-Preview names', async () => { + process.env.SCREENSHOT_TARGET_ENVIRONMENT = 'Production'; + try { + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + const res = await handler(event(deploymentStatusBody({ environment: 'Production' }))); + expect(res.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + } finally { + delete process.env.SCREENSHOT_TARGET_ENVIRONMENT; + } + }); + + test('400 when payload missing repo / deployment id / status id', async () => { + const res = await handler(event(deploymentStatusBody({ deploymentId: null }))); + expect(res.statusCode).toBe(400); + }); + + test('200 skipped when environment_url is missing', async () => { + const res = await handler(event(deploymentStatusBody({ environmentUrl: null }))); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).skipped_no_url).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 deduped when dedup row already exists', async () => { + ddbSend.mockRejectedValueOnce(new FakeConditionalCheckFailedException()); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).deduped).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('200 ok on the happy path: dedup put, processor invoked', async () => { + ddbSend.mockResolvedValueOnce({}); + lambdaSend.mockResolvedValueOnce({}); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + // Forwarded payload preserves the raw body verbatim. + const invokeArg = (lambdaSend.mock.calls[0][0] as { input: { Payload: Uint8Array } }).input; + const decoded = JSON.parse(new TextDecoder().decode(invokeArg.Payload)); + expect(decoded.raw_body).toBeDefined(); + }); + + test('rolls back the dedup row when processor invoke fails', async () => { + ddbSend + .mockResolvedValueOnce({}) // PutCommand + .mockResolvedValueOnce({}); // DeleteCommand cleanup + lambdaSend.mockRejectedValueOnce(new Error('lambda throttled')); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(500); + // Two ddb calls: put then delete-rollback. + expect(ddbSend).toHaveBeenCalledTimes(2); + const second = (ddbSend.mock.calls[1][0] as { _type: string }) ; + expect(second._type).toBe('Delete'); + }); + + test('returns 500 if dedup put throws a non-ConditionalCheck error', async () => { + ddbSend.mockRejectedValueOnce(new Error('DDB unavailable')); + const res = await handler(event(deploymentStatusBody())); + expect(res.statusCode).toBe(500); + }); +}); From e9fdfb6f8373778b270204efb5b0e64616d71c28 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 8 Jun 2026 13:45:41 -0400 Subject: [PATCH 3/3] test(screenshot): adapt cherry-picked pipeline tests to post-#240 API Updates captureScreenshot budget-arg + high-entropy S3 key assertions to match main's #240 versions, plus eslint formatting. --- .../handlers/github-webhook-processor.test.ts | 17 +++++++++++------ cdk/test/handlers/github-webhook.test.ts | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cdk/test/handlers/github-webhook-processor.test.ts b/cdk/test/handlers/github-webhook-processor.test.ts index b5091722..e857e1c3 100644 --- a/cdk/test/handlers/github-webhook-processor.test.ts +++ b/cdk/test/handlers/github-webhook-processor.test.ts @@ -156,16 +156,21 @@ describe('github-webhook-processor handler', () => { await handler(payload()); - expect(captureScreenshotMock).toHaveBeenCalledWith('https://preview.example.com'); + // captureScreenshot now receives a deadline-aware budget (PR-241 B1). + expect(captureScreenshotMock).toHaveBeenCalledWith( + 'https://preview.example.com', + expect.objectContaining({ timeoutMs: expect.any(Number) }), + ); expect(s3Send).toHaveBeenCalledTimes(1); const putArg = (s3Send.mock.calls[0][0] as { input: { Key: string; ContentType: string } }).input; - expect(putArg.Key).toBe('screenshots/owner_repo/abc1234-42.png'); + // Key carries the high-entropy suffix added in PR-241 (key entropy). + expect(putArg.Key).toMatch(/^screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png$/); expect(putArg.ContentType).toBe('image/png'); expect(upsertTaskCommentMock).toHaveBeenCalledTimes(1); const commentArg = upsertTaskCommentMock.mock.calls[0][0] as { repo: string; issueOrPrNumber: number; body: string }; expect(commentArg.repo).toBe('owner/repo'); expect(commentArg.issueOrPrNumber).toBe(17); - expect(commentArg.body).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + expect(commentArg.body).toMatch(/https:\/\/d1\.cloudfront\.net\/screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png/); }); test('aborts when screenshot capture throws', async () => { @@ -222,7 +227,7 @@ describe('github-webhook-processor handler', () => { expect(postIssueCommentMock).toHaveBeenCalledTimes(1); const linearArg = postIssueCommentMock.mock.calls[0]; expect(linearArg[1]).toBe('issue-uuid'); - expect(linearArg[2]).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png'); + expect(linearArg[2]).toMatch(/https:\/\/d1\.cloudfront\.net\/screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png/); }); test('falls back to extractor on PR body when title yields no identifier', async () => { @@ -232,8 +237,8 @@ describe('github-webhook-processor handler', () => { s3Send.mockResolvedValueOnce({}); upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' }); extractLinearIdentifierMock - .mockReturnValueOnce(null) // title produces no match - .mockReturnValueOnce('ABCA-42'); // body does + .mockReturnValueOnce(null) // title produces no match + .mockReturnValueOnce('ABCA-42'); // body does findLinearIssueMock.mockResolvedValueOnce({ issueId: 'issue-uuid', linearWorkspaceId: 'ws-1', diff --git a/cdk/test/handlers/github-webhook.test.ts b/cdk/test/handlers/github-webhook.test.ts index 58bc7435..d8d71f43 100644 --- a/cdk/test/handlers/github-webhook.test.ts +++ b/cdk/test/handlers/github-webhook.test.ts @@ -195,7 +195,7 @@ describe('github-webhook receiver', () => { test('rolls back the dedup row when processor invoke fails', async () => { ddbSend - .mockResolvedValueOnce({}) // PutCommand + .mockResolvedValueOnce({}) // PutCommand .mockResolvedValueOnce({}); // DeleteCommand cleanup lambdaSend.mockRejectedValueOnce(new Error('lambda throttled')); const res = await handler(event(deploymentStatusBody()));