mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-05 02:38:30 +08:00
* Match downloaded images to source URLs by asset identifier * Derive the signed URL asset identifier from the parsed path
1138 lines
32 KiB
TypeScript
1138 lines
32 KiB
TypeScript
import {
|
|
describe,
|
|
test,
|
|
expect,
|
|
spyOn,
|
|
beforeEach,
|
|
afterEach,
|
|
jest,
|
|
setSystemTime,
|
|
} from "bun:test";
|
|
import fs from "fs/promises";
|
|
import { downloadCommentImages } from "../src/github/utils/image-downloader";
|
|
import type { CommentWithImages } from "../src/github/utils/image-downloader";
|
|
import type { Octokits } from "../src/github/api/client";
|
|
|
|
// Asset URLs and their signed download URLs share the asset's GUID.
|
|
const GUID_1 = "f871c23e-a84d-4f1f-b9a0-86626c63f161";
|
|
const GUID_2 = "0b0c9d33-4e6a-4f4e-8a1a-2f9e5c6d7e8f";
|
|
const GUID_3 = "a1b2c3d4-e5f6-4789-abcd-ef0123456789";
|
|
|
|
const assetUrl = (guid: string, suffix = "") =>
|
|
`https://github.com/user-attachments/assets/${guid}${suffix}`;
|
|
|
|
const signedUrlFor = (guid: string, ext: string, token = "token") =>
|
|
`https://private-user-images.githubusercontent.com/12345/98765432-${guid}${ext}?jwt=${token}`;
|
|
|
|
describe("downloadCommentImages", () => {
|
|
let consoleLogSpy: any;
|
|
let consoleWarnSpy: any;
|
|
let consoleErrorSpy: any;
|
|
let fsMkdirSpy: any;
|
|
let fsWriteFileSpy: any;
|
|
let fetchSpy: any;
|
|
|
|
beforeEach(() => {
|
|
// Spy on console methods
|
|
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
// Spy on fs methods
|
|
fsMkdirSpy = spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
|
fsWriteFileSpy = spyOn(fs, "writeFile").mockResolvedValue(undefined);
|
|
|
|
// Set fake system time for consistent filenames
|
|
setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleLogSpy.mockRestore();
|
|
consoleWarnSpy.mockRestore();
|
|
consoleErrorSpy.mockRestore();
|
|
fsMkdirSpy.mockRestore();
|
|
fsWriteFileSpy.mockRestore();
|
|
if (fetchSpy) fetchSpy.mockRestore();
|
|
setSystemTime(); // Reset to real time
|
|
});
|
|
|
|
const createMockOctokit = (): Octokits => {
|
|
return {
|
|
rest: {
|
|
issues: {
|
|
getComment: jest.fn(),
|
|
get: jest.fn(),
|
|
},
|
|
pulls: {
|
|
getReviewComment: jest.fn(),
|
|
getReview: jest.fn(),
|
|
get: jest.fn(),
|
|
},
|
|
},
|
|
} as any as Octokits;
|
|
};
|
|
|
|
test("should create download directory", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const comments: CommentWithImages[] = [];
|
|
|
|
await downloadCommentImages(mockOctokit, "owner", "repo", comments);
|
|
|
|
expect(fsMkdirSpy).toHaveBeenCalledWith("/tmp/github-images", {
|
|
recursive: true,
|
|
});
|
|
});
|
|
|
|
test("should handle comments without images", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "123",
|
|
body: "This is a comment without images",
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining("Found"),
|
|
);
|
|
});
|
|
|
|
test("should detect and download images from issue comments", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// Mock octokit response
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
// Mock fetch for image download
|
|
const mockArrayBuffer = new ArrayBuffer(8);
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => mockArrayBuffer,
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "123",
|
|
body: `Here's an image: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
comment_id: 123,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
|
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
Buffer.from(mockArrayBuffer),
|
|
);
|
|
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in issue_comment 123",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
});
|
|
|
|
test("should save a JPEG from an extensionless URL with a .jpg extension", async () => {
|
|
// Regression for the case where a JPEG screenshot is pasted into an issue.
|
|
// GitHub serves it from /user-attachments/assets/<uuid> (no extension), so
|
|
// the URL-based guess used to default to ".png" while the bytes are JPEG —
|
|
// producing a mislabeled file that the Anthropic API rejected with a 400.
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".jpg");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
// JPEG magic bytes: FF D8 FF, then arbitrary padding.
|
|
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => jpegBytes.buffer,
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_body",
|
|
issueNumber: "143",
|
|
body: ``,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
|
"/tmp/github-images/image-1704067200000-0.jpg",
|
|
Buffer.from(jpegBytes.buffer),
|
|
);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.jpg",
|
|
);
|
|
});
|
|
|
|
test("should handle review comments", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1, ".jpg");
|
|
const signedUrl = signedUrlFor(GUID_1, ".jpg");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "review_comment",
|
|
id: "456",
|
|
body: `Review comment with image: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
comment_id: 456,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.jpg",
|
|
);
|
|
});
|
|
|
|
test("should handle review bodies", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "review_body",
|
|
id: "789",
|
|
pullNumber: "100",
|
|
body: `Review body: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
pull_number: 100,
|
|
review_id: 789,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
});
|
|
|
|
test("should handle issue bodies", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1, ".gif");
|
|
const signedUrl = signedUrlFor(GUID_1, ".gif");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_body",
|
|
issueNumber: "200",
|
|
body: `Issue description: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
issue_number: 200,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.gif",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in issue_body 200",
|
|
);
|
|
});
|
|
|
|
test("should handle PR bodies", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1, ".webp");
|
|
const signedUrl = signedUrlFor(GUID_1, ".webp");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "pr_body",
|
|
pullNumber: "300",
|
|
body: `PR description: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
pull_number: 300,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.webp",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in pr_body 300",
|
|
);
|
|
});
|
|
|
|
test("should handle multiple images in a single comment", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl1 = assetUrl(GUID_1);
|
|
const imageUrl2 = assetUrl(GUID_2, ".jpg");
|
|
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
|
const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "999",
|
|
body: `Two images:  and `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(result.size).toBe(2);
|
|
expect(result.get(imageUrl1)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(result.get(imageUrl2)).toBe(
|
|
"/tmp/github-images/image-1704067200000-1.jpg",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 2 image(s) in issue_comment 999",
|
|
);
|
|
});
|
|
|
|
test("should pair images by asset identifier even when the HTML order differs", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl1 = assetUrl(GUID_1);
|
|
const imageUrl2 = assetUrl(GUID_2);
|
|
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
|
const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2");
|
|
|
|
// The rendered HTML lists the second asset first.
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl2}"><img src="${signedUrl1}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "999",
|
|
body: `Two images:  and `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1);
|
|
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2);
|
|
expect(result.get(imageUrl1)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(result.get(imageUrl2)).toBe(
|
|
"/tmp/github-images/image-1704067200000-1.png",
|
|
);
|
|
});
|
|
|
|
test("should match asset identifiers case-insensitively", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1.toUpperCase());
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1002",
|
|
body: `Uppercase: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
});
|
|
|
|
test("should skip an image whose signed URL refers to a different asset", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
// The rendered HTML only contains a signed URL for a different asset.
|
|
const signedUrl = signedUrlFor(GUID_2, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1003",
|
|
body: `Original image: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(result.size).toBe(0);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
);
|
|
});
|
|
|
|
test("should not pair a signed URL that only names the asset in a leading path segment", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
// The path segment mentions the requested asset, but the URL resolves to a
|
|
// different asset's filename once ".." is applied.
|
|
const signedUrl = `https://private-user-images.githubusercontent.com/${GUID_1}/../12345/98765432-${GUID_2}.png?jwt=token`;
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<a href="${signedUrl}">${signedUrl}</a>`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1005",
|
|
body: `Original image: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(result.size).toBe(0);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
);
|
|
});
|
|
|
|
test("should skip an image URL without an asset identifier", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl =
|
|
"https://github.com/user-attachments/assets/test-image.png";
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1004",
|
|
body: `No identifier: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(result.size).toBe(0);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
`No matching signed URL found for ${imageUrl}, skipping`,
|
|
);
|
|
});
|
|
|
|
test("should skip already downloaded images", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "111",
|
|
body: `First: `,
|
|
},
|
|
{
|
|
type: "issue_comment",
|
|
id: "222",
|
|
body: `Second: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
});
|
|
|
|
test("should handle missing HTML body", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: null,
|
|
},
|
|
});
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "333",
|
|
body: `Missing HTML: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
"No HTML body found for issue_comment 333",
|
|
);
|
|
});
|
|
|
|
test("should handle fetch errors", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: "Not Found",
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "444",
|
|
body: `Error image: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
`✗ Failed to download ${imageUrl}:`,
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
test("should handle API errors gracefully", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest
|
|
.fn()
|
|
.mockRejectedValue(new Error("API rate limit exceeded"));
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "555",
|
|
body: `API error: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
"Failed to process images for issue_comment 555:",
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
test("should extract correct file extensions", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const extensions = [
|
|
{ url: assetUrl(GUID_1, ".png"), ext: ".png" },
|
|
{ url: assetUrl(GUID_1, ".jpg"), ext: ".jpg" },
|
|
{ url: assetUrl(GUID_1, ".jpeg"), ext: ".jpeg" },
|
|
{ url: assetUrl(GUID_1, ".gif"), ext: ".gif" },
|
|
{ url: assetUrl(GUID_1, ".webp"), ext: ".webp" },
|
|
{ url: assetUrl(GUID_1, ".svg"), ext: ".svg" },
|
|
// default
|
|
{ url: assetUrl(GUID_1), ext: ".png" },
|
|
];
|
|
|
|
let callIndex = 0;
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrlFor(GUID_1, "")}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
for (const { url, ext } of extensions) {
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: `${1000 + callIndex}`,
|
|
body: `Test: `,
|
|
},
|
|
];
|
|
|
|
setSystemTime(new Date(1704067200000 + callIndex));
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
expect(result.get(url)).toBe(
|
|
`/tmp/github-images/image-${1704067200000 + callIndex}-0${ext}`,
|
|
);
|
|
|
|
// Reset for next iteration
|
|
fsWriteFileSpy.mockClear();
|
|
callIndex++;
|
|
}
|
|
});
|
|
|
|
test("should handle a signed URL missing for one of several images", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl1 = assetUrl(GUID_1);
|
|
const imageUrl2 = assetUrl(GUID_2);
|
|
const signedUrl1 = signedUrlFor(GUID_1, ".png");
|
|
|
|
// Only one signed URL for two images
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl1}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "666",
|
|
body: `Two images:  `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl1)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(result.get(imageUrl2)).toBeUndefined();
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
`No matching signed URL found for ${imageUrl2}, skipping`,
|
|
);
|
|
});
|
|
|
|
test("should detect and download images from HTML img tags", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// Mock octokit response
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
// Mock fetch for image download
|
|
const mockArrayBuffer = new ArrayBuffer(8);
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => mockArrayBuffer,
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "777",
|
|
body: `Here's an HTML image: <img src="${imageUrl}" alt="test">`,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
|
owner: "owner",
|
|
repo: "repo",
|
|
comment_id: 777,
|
|
mediaType: { format: "full+json" },
|
|
});
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
|
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
Buffer.from(mockArrayBuffer),
|
|
);
|
|
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in issue_comment 777",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
});
|
|
|
|
test("should handle HTML img tags with different quote styles", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl1 = assetUrl(GUID_1, ".jpg");
|
|
const imageUrl2 = assetUrl(GUID_2, ".png");
|
|
const signedUrl1 = signedUrlFor(GUID_1, ".jpg", "token1");
|
|
const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "888",
|
|
body: `Single quote: <img src='${imageUrl1}' alt="test"> and double quote: <img src="${imageUrl2}" alt="test">`,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(result.size).toBe(2);
|
|
expect(result.get(imageUrl1)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.jpg",
|
|
);
|
|
expect(result.get(imageUrl2)).toBe(
|
|
"/tmp/github-images/image-1704067200000-1.png",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 2 image(s) in issue_comment 888",
|
|
);
|
|
});
|
|
|
|
test("should handle mixed Markdown and HTML images", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const markdownUrl = assetUrl(GUID_1);
|
|
const htmlUrl = assetUrl(GUID_2, ".jpg");
|
|
const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1");
|
|
const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2");
|
|
|
|
// The rendered HTML has the images in document order (HTML tag first),
|
|
// which is the reverse of the order in which the URLs are extracted.
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl2}"><img src="${signedUrl1}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "999",
|
|
body: `HTML: <img src="${htmlUrl}" alt="test"> and Markdown: `,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(result.size).toBe(2);
|
|
expect(result.get(markdownUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(result.get(htmlUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-1.jpg",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 2 image(s) in issue_comment 999",
|
|
);
|
|
});
|
|
|
|
test("should deduplicate identical URLs from Markdown and HTML", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_1);
|
|
const signedUrl = signedUrlFor(GUID_1, ".png");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1000",
|
|
body: `Same image twice:  and <img src="${imageUrl}" alt="test">`,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.png",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in issue_comment 1000",
|
|
);
|
|
});
|
|
|
|
test("should handle HTML img tags with additional attributes", async () => {
|
|
const mockOctokit = createMockOctokit();
|
|
const imageUrl = assetUrl(GUID_3, ".webp");
|
|
const signedUrl = signedUrlFor(GUID_3, ".webp");
|
|
|
|
// @ts-expect-error Mock implementation doesn't match full type signature
|
|
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
|
data: {
|
|
body_html: `<img src="${signedUrl}">`,
|
|
},
|
|
});
|
|
|
|
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
|
ok: true,
|
|
arrayBuffer: async () => new ArrayBuffer(8),
|
|
} as Response);
|
|
|
|
const comments: CommentWithImages[] = [
|
|
{
|
|
type: "issue_comment",
|
|
id: "1001",
|
|
body: `Complex tag: <img class="image" src="${imageUrl}" alt="test image" width="100" height="200">`,
|
|
},
|
|
];
|
|
|
|
const result = await downloadCommentImages(
|
|
mockOctokit,
|
|
"owner",
|
|
"repo",
|
|
comments,
|
|
);
|
|
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
expect(result.size).toBe(1);
|
|
expect(result.get(imageUrl)).toBe(
|
|
"/tmp/github-images/image-1704067200000-0.webp",
|
|
);
|
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
"Found 1 image(s) in issue_comment 1001",
|
|
);
|
|
});
|
|
});
|