Skip to content

Commit 6a13f1e

Browse files
committed
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.
1 parent 059450e commit 6a13f1e

5 files changed

Lines changed: 1129 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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+
expect(captureScreenshotMock).toHaveBeenCalledWith('https://preview.example.com');
160+
expect(s3Send).toHaveBeenCalledTimes(1);
161+
const putArg = (s3Send.mock.calls[0][0] as { input: { Key: string; ContentType: string } }).input;
162+
expect(putArg.Key).toBe('screenshots/owner_repo/abc1234-42.png');
163+
expect(putArg.ContentType).toBe('image/png');
164+
expect(upsertTaskCommentMock).toHaveBeenCalledTimes(1);
165+
const commentArg = upsertTaskCommentMock.mock.calls[0][0] as { repo: string; issueOrPrNumber: number; body: string };
166+
expect(commentArg.repo).toBe('owner/repo');
167+
expect(commentArg.issueOrPrNumber).toBe(17);
168+
expect(commentArg.body).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png');
169+
});
170+
171+
test('aborts when screenshot capture throws', async () => {
172+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
173+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
174+
captureScreenshotMock.mockRejectedValueOnce(new Error('CDP failed'));
175+
176+
await handler(payload());
177+
178+
expect(s3Send).not.toHaveBeenCalled();
179+
expect(upsertTaskCommentMock).not.toHaveBeenCalled();
180+
});
181+
182+
test('aborts when S3 PutObject throws', async () => {
183+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
184+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
185+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
186+
s3Send.mockRejectedValueOnce(new Error('S3 throttled'));
187+
188+
await handler(payload());
189+
190+
expect(upsertTaskCommentMock).not.toHaveBeenCalled();
191+
});
192+
193+
test('PR comment failure is non-fatal (log + continue)', async () => {
194+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
195+
fetchOk([{ number: 17, state: 'open', title: 't', body: '' }]);
196+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
197+
s3Send.mockResolvedValueOnce({});
198+
upsertTaskCommentMock.mockRejectedValueOnce(new Error('GitHub 502'));
199+
200+
// Should not throw — the handler is best-effort.
201+
await expect(handler(payload())).resolves.toBeUndefined();
202+
});
203+
204+
test('Linear branch fires when registry table set + identifier in PR title', async () => {
205+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
206+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix login', body: 'body' }]);
207+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
208+
s3Send.mockResolvedValueOnce({});
209+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
210+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
211+
findLinearIssueMock.mockResolvedValueOnce({
212+
issueId: 'issue-uuid',
213+
linearWorkspaceId: 'ws-1',
214+
workspaceSlug: 'abca',
215+
});
216+
postIssueCommentMock.mockResolvedValueOnce(true);
217+
218+
await handler(payload());
219+
220+
expect(extractLinearIdentifierMock).toHaveBeenCalledWith('ABCA-42 fix login');
221+
expect(findLinearIssueMock).toHaveBeenCalledWith('ABCA-42', 'LinearWorkspaceRegistry');
222+
expect(postIssueCommentMock).toHaveBeenCalledTimes(1);
223+
const linearArg = postIssueCommentMock.mock.calls[0];
224+
expect(linearArg[1]).toBe('issue-uuid');
225+
expect(linearArg[2]).toContain('https://d1.cloudfront.net/screenshots/owner_repo/abc1234-42.png');
226+
});
227+
228+
test('falls back to extractor on PR body when title yields no identifier', async () => {
229+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
230+
fetchOk([{ number: 17, state: 'open', title: 'feat: add foo', body: 'closes ABCA-42' }]);
231+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
232+
s3Send.mockResolvedValueOnce({});
233+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
234+
extractLinearIdentifierMock
235+
.mockReturnValueOnce(null) // title produces no match
236+
.mockReturnValueOnce('ABCA-42'); // body does
237+
findLinearIssueMock.mockResolvedValueOnce({
238+
issueId: 'issue-uuid',
239+
linearWorkspaceId: 'ws-1',
240+
workspaceSlug: 'abca',
241+
});
242+
postIssueCommentMock.mockResolvedValueOnce(true);
243+
244+
await handler(payload());
245+
246+
expect(extractLinearIdentifierMock).toHaveBeenCalledTimes(2);
247+
expect(postIssueCommentMock).toHaveBeenCalledTimes(1);
248+
});
249+
250+
test('skips Linear when no identifier extracted', async () => {
251+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
252+
fetchOk([{ number: 17, state: 'open', title: 'no id', body: 'no id' }]);
253+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
254+
s3Send.mockResolvedValueOnce({});
255+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
256+
extractLinearIdentifierMock.mockReturnValue(null);
257+
258+
await handler(payload());
259+
260+
expect(findLinearIssueMock).not.toHaveBeenCalled();
261+
expect(postIssueCommentMock).not.toHaveBeenCalled();
262+
});
263+
264+
test('skips Linear post when identifier does not resolve to an issue', async () => {
265+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
266+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 stale', body: '' }]);
267+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
268+
s3Send.mockResolvedValueOnce({});
269+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
270+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
271+
findLinearIssueMock.mockResolvedValueOnce(null);
272+
273+
await handler(payload());
274+
275+
expect(postIssueCommentMock).not.toHaveBeenCalled();
276+
});
277+
278+
test('Linear comment failure does not propagate (best-effort)', async () => {
279+
resolveGitHubTokenMock.mockResolvedValue('gh-tok');
280+
fetchOk([{ number: 17, state: 'open', title: 'ABCA-42 fix', body: '' }]);
281+
captureScreenshotMock.mockResolvedValueOnce(new Uint8Array([1]));
282+
s3Send.mockResolvedValueOnce({});
283+
upsertTaskCommentMock.mockResolvedValueOnce({ commentId: 'cmt-1' });
284+
extractLinearIdentifierMock.mockReturnValueOnce('ABCA-42');
285+
findLinearIssueMock.mockResolvedValueOnce({
286+
issueId: 'issue-uuid',
287+
linearWorkspaceId: 'ws-1',
288+
workspaceSlug: 'abca',
289+
});
290+
postIssueCommentMock.mockResolvedValueOnce(false);
291+
292+
// No throw — postIssueComment returning false is just logged.
293+
await expect(handler(payload())).resolves.toBeUndefined();
294+
});
295+
});

0 commit comments

Comments
 (0)