Skip to content

Commit af33bc0

Browse files
isadekskrokoko
andauthored
feat(screenshot): skip screenshot when preview URL returns non-2xx (#289)
* feat(screenshot): skip screenshot when preview URL returns non-2xx 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. * 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. * 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. --------- Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent 75f1993 commit af33bc0

6 files changed

Lines changed: 852 additions & 3 deletions

File tree

cdk/src/handlers/shared/agentcore-browser.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,14 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
262262
});
263263
}
264264

265+
// Track the main-document HTTP response so we can fail fast on 4xx/5xx
266+
// (404 / 503 / auth wall pages) instead of capturing what looks like the
267+
// app but isn't. Captured in a Network.responseReceived listener below;
268+
// checked after Page.loadEventFired but before Page.captureScreenshot.
269+
// (Auth walls that return 200 are out of scope — see issue #287.)
270+
let mainDocumentStatus: number | null = null;
271+
let mainDocumentFrameId: string | null = null;
272+
265273
try {
266274
// 1. List existing targets, find the default about:blank page.
267275
const targetsResp = await cdpSend('Target.getTargets');
@@ -281,9 +289,37 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
281289
throw new Error('Target.attachToTarget did not return a sessionId');
282290
}
283291

284-
// 3. Enable Page domain so we get the `Page.loadEventFired` event
285-
// we wait on below.
292+
// 3. Enable Page + Network so we get the `Page.loadEventFired` event
293+
// we wait on below AND the main-document response status. Network
294+
// has to be enabled BEFORE Page.navigate, or the response event
295+
// fires before our listener is wired and we miss the status.
286296
await cdpSend('Page.enable', {}, pageSessionId);
297+
await cdpSend('Network.enable', {}, pageSessionId);
298+
299+
// Tap the raw message stream for Network.responseReceived events —
300+
// we want a multi-fire listener (Document responses can appear for
301+
// redirect chains), not the one-shot waiter pattern that
302+
// eventWaiters / waitForEvent use. Records the latest matching
303+
// status; the post-load check below acts on whatever was captured.
304+
ws.on('message', (raw: RawData) => {
305+
let msg: CdpMessage;
306+
try {
307+
msg = JSON.parse(raw.toString()) as CdpMessage;
308+
} catch {
309+
return;
310+
}
311+
if (msg.method !== 'Network.responseReceived') return;
312+
const params = msg.params as
313+
| { type?: string; frameId?: string; response?: { status?: number } }
314+
| undefined;
315+
// CDP's `Network.responseReceived` fires for every resource (HTML,
316+
// JS, CSS, images, XHR, …). Only the type==='Document' event for
317+
// the navigated frame is the main-document response we care about.
318+
if (!params || params.type !== 'Document') return;
319+
if (mainDocumentFrameId && params.frameId !== mainDocumentFrameId) return;
320+
const status = params.response?.status;
321+
if (typeof status === 'number') mainDocumentStatus = status;
322+
});
287323

288324
// 4. Navigate. The response includes a `frameId`; we wait on the
289325
// `Page.loadEventFired` event below (more reliable than
@@ -294,6 +330,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
294330
if (navError) {
295331
throw new Error(`Page.navigate failed: ${navError}`);
296332
}
333+
mainDocumentFrameId = (navResp.result?.frameId as string | undefined) ?? null;
297334

298335
// 5. Wait for the page load event. SPA-style apps may continue
299336
// fetching after this fires, so add a 2s settle wait. For
@@ -302,7 +339,20 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
302339
await waitForEvent('Page.loadEventFired');
303340
await new Promise((r) => setTimeout(r, 2000));
304341

305-
// 6. Take the screenshot.
342+
// 6. Reject non-2xx main-document statuses before screenshotting.
343+
// A 404 / 503 / auth wall renders a "successful" page from CDP's
344+
// perspective; the user sees a confidently-wrong screenshot of an
345+
// error page posted as the deploy preview. Throw → processor's
346+
// catch logs and skips the PR/Linear comment cleanly.
347+
// If we never captured a status (Network.responseReceived was
348+
// queued but predicate didn't match — e.g. a redirect chain that
349+
// doesn't expose the final frame), fall through and capture
350+
// optimistically; that's the pre-#287 behaviour.
351+
if (mainDocumentStatus !== null && (mainDocumentStatus < 200 || mainDocumentStatus >= 300)) {
352+
throw new Error(`Preview URL returned HTTP ${mainDocumentStatus}; skipping screenshot`);
353+
}
354+
355+
// 7. Take the screenshot.
306356
const shotResp = await cdpSend('Page.captureScreenshot', {
307357
format: 'png',
308358
captureBeyondViewport: true,
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
const s3Send = jest.fn();
21+
jest.mock('@aws-sdk/client-s3', () => ({
22+
S3Client: jest.fn(() => ({ send: s3Send })),
23+
PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })),
24+
}));
25+
26+
const captureScreenshotMock = jest.fn();
27+
jest.mock('../../src/handlers/shared/agentcore-browser', () => ({
28+
captureScreenshot: (...args: unknown[]) => captureScreenshotMock(...args),
29+
}));
30+
31+
const resolveGitHubTokenMock = jest.fn();
32+
jest.mock('../../src/handlers/shared/context-hydration', () => ({
33+
resolveGitHubToken: (...args: unknown[]) => resolveGitHubTokenMock(...args),
34+
}));
35+
36+
const upsertTaskCommentMock = jest.fn();
37+
jest.mock('../../src/handlers/shared/github-comment', () => ({
38+
upsertTaskComment: (...args: unknown[]) => upsertTaskCommentMock(...args),
39+
}));
40+
41+
const postIssueCommentMock = jest.fn();
42+
jest.mock('../../src/handlers/shared/linear-feedback', () => ({
43+
postIssueComment: (...args: unknown[]) => postIssueCommentMock(...args),
44+
}));
45+
46+
const findLinearIssueMock = jest.fn();
47+
const extractLinearIdentifierMock = jest.fn();
48+
jest.mock('../../src/handlers/shared/linear-issue-lookup', () => ({
49+
findLinearIssueByIdentifier: (...args: unknown[]) => findLinearIssueMock(...args),
50+
extractLinearIdentifier: (...args: unknown[]) => extractLinearIdentifierMock(...args),
51+
}));
52+
53+
process.env.SCREENSHOT_BUCKET_NAME = 'screenshot-bucket';
54+
process.env.SCREENSHOT_PUBLIC_HOST = 'd1.cloudfront.net';
55+
process.env.GITHUB_TOKEN_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-token';
56+
process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry';
57+
58+
import { handler } from '../../src/handlers/github-webhook-processor';
59+
60+
function payload(overrides: Record<string, unknown> = {}): { raw_body: string } {
61+
const body = {
62+
deployment_status: {
63+
id: 99,
64+
state: 'success',
65+
environment_url: 'https://preview.example.com',
66+
},
67+
deployment: { id: 42, sha: 'abc1234', environment: 'Preview' },
68+
repository: { full_name: 'owner/repo' },
69+
...overrides,
70+
};
71+
return { raw_body: JSON.stringify(body) };
72+
}
73+
74+
function fetchOk(jsonValue: unknown, status = 200): jest.SpyInstance {
75+
return jest.spyOn(global, 'fetch').mockResolvedValueOnce({
76+
ok: status >= 200 && status < 300,
77+
status,
78+
json: async () => jsonValue,
79+
} as unknown as Response);
80+
}
81+
82+
describe('github-webhook-processor handler', () => {
83+
beforeEach(() => {
84+
s3Send.mockReset();
85+
captureScreenshotMock.mockReset();
86+
resolveGitHubTokenMock.mockReset();
87+
upsertTaskCommentMock.mockReset();
88+
postIssueCommentMock.mockReset();
89+
findLinearIssueMock.mockReset();
90+
extractLinearIdentifierMock.mockReset();
91+
jest.restoreAllMocks();
92+
});
93+
94+
test('returns silently when raw_body is empty', async () => {
95+
await handler({ raw_body: '' });
96+
expect(resolveGitHubTokenMock).not.toHaveBeenCalled();
97+
});
98+
99+
test('returns silently when raw_body is malformed JSON', async () => {
100+
await handler({ raw_body: 'not-json{' });
101+
expect(resolveGitHubTokenMock).not.toHaveBeenCalled();
102+
});
103+
104+
test('returns when payload missing repo/sha/preview_url', async () => {
105+
await handler({ raw_body: JSON.stringify({ deployment: { id: 42 } }) });
106+
expect(resolveGitHubTokenMock).not.toHaveBeenCalled();
107+
});
108+
109+
test('returns when GitHub token cannot be resolved', async () => {
110+
resolveGitHubTokenMock.mockRejectedValueOnce(new Error('SM unavailable'));
111+
await handler(payload());
112+
expect(captureScreenshotMock).not.toHaveBeenCalled();
113+
});
114+
115+
test('returns when no open PR is associated with the SHA after retries', async () => {
116+
jest.useFakeTimers();
117+
try {
118+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
119+
// Four calls (delays = [0, 5s, 10s, 20s]) all return empty list.
120+
fetchOk([]);
121+
fetchOk([]);
122+
fetchOk([]);
123+
fetchOk([]);
124+
const promise = handler(payload());
125+
await jest.runAllTimersAsync();
126+
await promise;
127+
expect(captureScreenshotMock).not.toHaveBeenCalled();
128+
} finally {
129+
jest.useRealTimers();
130+
}
131+
});
132+
133+
test('only OPEN PRs are accepted (closed/merged are filtered)', async () => {
134+
jest.useFakeTimers();
135+
try {
136+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
137+
fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]);
138+
fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]);
139+
fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]);
140+
fetchOk([{ number: 1, state: 'closed', title: 'old', body: '' }]);
141+
const promise = handler(payload());
142+
await jest.runAllTimersAsync();
143+
await promise;
144+
expect(captureScreenshotMock).not.toHaveBeenCalled();
145+
} finally {
146+
jest.useRealTimers();
147+
}
148+
});
149+
150+
test('happy path: PR found → screenshot → S3 → PR comment posted', async () => {
151+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
152+
fetchOk([{ number: 17, state: 'open', title: 'feat: add x', body: 'body' }]);
153+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1, 2, 3]));
154+
s3Send.mockResolvedValueOnce({});
155+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
156+
157+
await handler(payload());
158+
159+
// captureScreenshot now receives a deadline-aware budget (PR-241 B1).
160+
expect(captureScreenshotMock).toHaveBeenCalledWith(
161+
'https://preview.example.com',
162+
expect.objectContaining({ timeoutMs: expect.any(Number) }),
163+
);
164+
expect(s3Send).toHaveBeenCalledTimes(1);
165+
const putArg = (s3Send.mock.calls[0][0] as { input: { Key: string; ContentType: string } }).input;
166+
// Key carries the high-entropy suffix added in PR-241 (key entropy).
167+
expect(putArg.Key).toMatch(/^screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png$/);
168+
expect(putArg.ContentType).toBe('image/png');
169+
expect(upsertTaskCommentMock).toHaveBeenCalledTimes(1);
170+
const commentArg = upsertTaskCommentMock.mock.calls[0][0] as { repo: string; issueOrPrNumber: number; body: string };
171+
expect(commentArg.repo).toBe('owner/repo');
172+
expect(commentArg.issueOrPrNumber).toBe(17);
173+
expect(commentArg.body).toMatch(/https:\/\/d1\.cloudfront\.net\/screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png/);
174+
});
175+
176+
test('aborts when screenshot capture throws', async () => {
177+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
178+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
179+
captureScreenshotMock.mockRejectedValueOnce(new Error('CDP failed'));
180+
181+
await handler(payload());
182+
183+
expect(s3Send).not.toHaveBeenCalled();
184+
expect(upsertTaskCommentMock).not.toHaveBeenCalled();
185+
});
186+
187+
test('aborts when S3 PutObject throws', async () => {
188+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
189+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
190+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
191+
s3Send.mockRejectedValueOnce(new Error('S3 throttled'));
192+
193+
await handler(payload());
194+
195+
expect(upsertTaskCommentMock).not.toHaveBeenCalled();
196+
});
197+
198+
test('PR comment failure is non-fatal (log + continue)', async () => {
199+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
200+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
201+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
202+
s3Send.mockResolvedValueOnce({});
203+
upsertTaskCommentMock.mockRejectedValueOnce(new Error('GitHub 502'));
204+
205+
// Should not throw — the handler is best-effort.
206+
await expect(handler(payload())).resolves.toBeUndefined();
207+
});
208+
209+
test('Linear branch fires when registry table set + identifier in PR title', async () => {
210+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
211+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix login', body: 'body' }]);
212+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
213+
s3Send.mockResolvedValueOnce({});
214+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
215+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
216+
findLinearIssueMock.mockResolvedValueOnce({
217+
issueId: 'issue-uuid',
218+
linearWorkspaceId: 'ws-1',
219+
workspaceSlug: 'abca',
220+
});
221+
postIssueCommentMock.mockResolvedValueOnce(true);
222+
223+
await handler(payload());
224+
225+
expect(extractLinearIdentifierMock).toHaveBeenCalledWith('ABCA-42 fix login');
226+
expect(findLinearIssueMock).toHaveBeenCalledWith('ABCA-42', 'LinearWorkspaceRegistry');
227+
expect(postIssueCommentMock).toHaveBeenCalledTimes(1);
228+
const linearArg = postIssueCommentMock.mock.calls[0];
229+
expect(linearArg[1]).toBe('issue-uuid');
230+
expect(linearArg[2]).toMatch(/https:\/\/d1\.cloudfront\.net\/screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png/);
231+
});
232+
233+
test('falls back to extractor on PR body when title yields no identifier', async () => {
234+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
235+
fetchOk([{ number: 17, state: 'open', title: 'feat: add foo', body: 'closes ABCA-42' }]);
236+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
237+
s3Send.mockResolvedValueOnce({});
238+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
239+
extractLinearIdentifierMock
240+
.mockReturnValueOnce(null) // title produces no match
241+
.mockReturnValueOnce('ABCA-42'); // body does
242+
findLinearIssueMock.mockResolvedValueOnce({
243+
issueId: 'issue-uuid',
244+
linearWorkspaceId: 'ws-1',
245+
workspaceSlug: 'abca',
246+
});
247+
postIssueCommentMock.mockResolvedValueOnce(true);
248+
249+
await handler(payload());
250+
251+
expect(extractLinearIdentifierMock).toHaveBeenCalledTimes(2);
252+
expect(postIssueCommentMock).toHaveBeenCalledTimes(1);
253+
});
254+
255+
test('skips Linear when no identifier extracted', async () => {
256+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
257+
fetchOk([{ number: 17, state: 'open', title: 'no id', body: 'no id' }]);
258+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
259+
s3Send.mockResolvedValueOnce({});
260+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
261+
extractLinearIdentifierMock.mockReturnValue(null);
262+
263+
await handler(payload());
264+
265+
expect(findLinearIssueMock).not.toHaveBeenCalled();
266+
expect(postIssueCommentMock).not.toHaveBeenCalled();
267+
});
268+
269+
test('skips Linear post when identifier does not resolve to an issue', async () => {
270+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
271+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 stale', body: '' }]);
272+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
273+
s3Send.mockResolvedValueOnce({});
274+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
275+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
276+
findLinearIssueMock.mockResolvedValueOnce(null);
277+
278+
await handler(payload());
279+
280+
expect(postIssueCommentMock).not.toHaveBeenCalled();
281+
});
282+
283+
test('Linear comment failure does not propagate (best-effort)', async () => {
284+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
285+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix', body: '' }]);
286+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
287+
s3Send.mockResolvedValueOnce({});
288+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
289+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
290+
findLinearIssueMock.mockResolvedValueOnce({
291+
issueId: 'issue-uuid',
292+
linearWorkspaceId: 'ws-1',
293+
workspaceSlug: 'abca',
294+
});
295+
postIssueCommentMock.mockResolvedValueOnce(false);
296+
297+
// No throw — postIssueComment returning false is just logged.
298+
await expect(handler(payload())).resolves.toBeUndefined();
299+
});
300+
});

0 commit comments

Comments
 (0)