Last active
December 21, 2024 04:37
-
-
Save YonatanKra/8b40bed199d52ed083a7b0446d029081 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AltTextBot } from './find-altless-posts.js'; | |
let mockAtpAgent; | |
const resolvedHandle = { | |
did: 'handle-did' | |
}; | |
const resetAtpAgentMock = () => { | |
mockAtpAgent = { | |
getPost: vi.fn(), | |
resolveHandle: vi.fn().mockResolvedValue({ | |
data: resolvedHandle | |
}), | |
login: vi.fn(), | |
getAuthorFeed: vi.fn(), | |
}; | |
} | |
vi.mock('@atproto/api', () => ({ | |
AtpAgent: vi.fn(() => mockAtpAgent), | |
})); | |
describe('AltTextBot', () => { | |
const postUri = 'postUri'; | |
const handle = 'testUser'; | |
const imageWithoutAlt = { alt: '' }; | |
const imageWithAlt = { alt: 'I have Alt text!' }; | |
const emptyFeedResponse = { | |
data: { | |
feed: [], | |
cursor: undefined, | |
}, | |
}; | |
const fullFeedResponse = { | |
data: { | |
feed: [ | |
{ | |
post: { | |
uri: 'postUri1', cid: 'postCid1', | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
}, | |
record: { | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
} | |
} | |
} | |
}, | |
{ | |
post: { | |
uri: 'postUri2', cid: 'postCid2', | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
}, | |
record: { | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
} | |
} | |
} | |
}, | |
], | |
cursor: 'nextCursor', | |
}, | |
}; | |
const fullFeedLastResponse = { | |
data: { | |
feed: [ | |
{ | |
post: { | |
uri: 'postUri1', cid: 'postCid1', embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
}, | |
record: { | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
} | |
} | |
} | |
}, | |
{ | |
post: { | |
uri: 'postUri2', cid: 'postCid2', embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
}, | |
record: { | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
} | |
} | |
} | |
}, | |
] | |
}, | |
}; | |
let bot: AltTextBot; | |
function queueAgentFeedResponse(response) { | |
mockAtpAgent.getAuthorFeed.mockResolvedValueOnce(response); | |
} | |
beforeEach(async () => { | |
resetAtpAgentMock(); | |
bot = new AltTextBot(); | |
}); | |
it('should initialize a new instance', async () => { | |
expect(bot).toBeDefined(); | |
}); | |
describe('checkSinglePost()', () => { | |
it('should return the error message if fetch post failed', async () => { | |
const error = { message: 'error' }; | |
mockAtpAgent.getPost.mockRejectedValue(error); | |
const result = await bot.checkSinglePost(postUri); | |
expect(result).toEqual(error); | |
}); | |
it('should get the post using atpAgent', async () => { | |
const postUri = 'https://bsky.app/profile/yonatankra.com/post/3lczalvz7uk2l'; | |
mockAtpAgent.getPost.mockResolvedValueOnce(postUri); | |
await bot.checkSinglePost(postUri); | |
expect(mockAtpAgent.getPost).toHaveBeenCalledWith({ | |
"collection": "app.bsky.feed.post", | |
"repo": "handle-did", | |
"rkey": "3lczalvz7uk2l", | |
}); | |
expect(mockAtpAgent.getPost).toHaveBeenCalledTimes(1); | |
}); | |
it('should return the post with altLess images list', async () => { | |
const post = { | |
uri: 'postUri', | |
cid: 'postCid', | |
value: { | |
embed: { | |
images: [imageWithoutAlt, imageWithAlt, imageWithoutAlt], | |
$type: 'image' | |
}, | |
text: '', | |
createdAt: '' | |
}, | |
}; | |
mockAtpAgent.getPost.mockResolvedValueOnce(post); | |
const result = await bot.checkSinglePost(postUri); | |
expect(result).toEqual({ post, imagesWithoutAlt: [imageWithoutAlt, imageWithoutAlt] }) | |
}); | |
}); | |
describe('login', () => { | |
it('should login using AtProto SDK', async () => { | |
const handle = 'testUser'; | |
const password = 'testPassword'; | |
await bot.login(handle, password); | |
expect(mockAtpAgent.login).toHaveBeenCalledWith({ | |
identifier: handle, | |
password: password, | |
}); | |
}); | |
}); | |
describe('streamPosts', () => { | |
beforeEach(() => { | |
vi.useFakeTimers(); | |
}); | |
afterEach(() => { | |
vi.useRealTimers(); | |
}); | |
it('should call getAuthorFeed with the correct parameters', async () => { | |
queueAgentFeedResponse(emptyFeedResponse); | |
await bot.streamPosts(handle, () => {}); | |
expect(mockAtpAgent.getAuthorFeed.mock.calls[0][0]).toEqual({ | |
actor: handle, | |
limit: 20, | |
cursor: undefined, | |
filter: 'posts_with_media' | |
}); | |
}); | |
it('should log when there are no more posts and break', async () => { | |
queueAgentFeedResponse(emptyFeedResponse); | |
await bot.streamPosts(handle, () => {}); | |
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1); | |
}); | |
it('should break if no cursor is provided in the feed response', async () => { | |
queueAgentFeedResponse({ | |
data: { | |
feed: [ | |
{ post: { uri: 'postUri1', cid: 'postCid1', embed: {} } }, | |
{ post: { uri: 'postUri2', cid: 'postCid2', embed: {} } }, | |
], | |
cursor: undefined | |
} | |
}); | |
await bot.streamPosts(handle, () => {}); | |
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(1); | |
}); | |
it('should retry after 5 seconds if author feed rejected', async () => { | |
vi.useFakeTimers(); | |
const fetchError = new Error('Fetch error'); | |
mockAtpAgent.getAuthorFeed.mockRejectedValueOnce(fetchError); | |
bot.streamPosts(handle, () => {}); | |
await vi.advanceTimersByTimeAsync(4999); | |
const callsBefore5Seconds = mockAtpAgent.getAuthorFeed.mock.calls.length; | |
await vi.advanceTimersByTimeAsync(1); | |
expect(callsBefore5Seconds).toBe(1); | |
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(2); | |
vi.useRealTimers(); | |
}); | |
it('should stream posts with done set to false when cursor is truthy', async () => { | |
const spy = vi.fn(); | |
queueAgentFeedResponse(fullFeedResponse); | |
queueAgentFeedResponse(fullFeedResponse); | |
queueAgentFeedResponse(fullFeedLastResponse); | |
await bot.streamPosts(handle, spy); | |
expect(spy.mock.calls[0][0]) | |
.toEqual({result: fullFeedResponse.data.feed, done: false}); | |
expect(spy.mock.calls[1][0]) | |
.toEqual({result: fullFeedResponse.data.feed, done: false}); | |
expect(spy.mock.calls[2][0]) | |
.toEqual({result: fullFeedLastResponse.data.feed, done: true}); | |
}); | |
it('should send done true to callback when response cursor is empty', async () => { | |
const spy = vi.fn(); | |
queueAgentFeedResponse(fullFeedLastResponse); | |
await bot.streamPosts(handle, spy); | |
expect(spy.mock.calls[0][0]) | |
.toEqual({result: fullFeedLastResponse.data.feed, done: true}); | |
}); | |
it('should send done true to callback when feed returns empty', async () => { | |
const spy = vi.fn(); | |
queueAgentFeedResponse(emptyFeedResponse); | |
await bot.streamPosts(handle, spy); | |
expect(spy.mock.calls[0][0]).toEqual({ done: true }); | |
}); | |
it('should send the last response cursor in the next request', async () => { | |
const spy = vi.fn(); | |
queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-0' } }); | |
queueAgentFeedResponse({ data: { ...fullFeedResponse.data, cursor: 'next-1' } }); | |
queueAgentFeedResponse(fullFeedLastResponse); | |
await bot.streamPosts(handle, spy); | |
expect(mockAtpAgent.getAuthorFeed.mock.calls[1][0].cursor).toBe('next-0'); | |
expect(mockAtpAgent.getAuthorFeed.mock.calls[2][0].cursor).toBe('next-1'); | |
expect(mockAtpAgent.getAuthorFeed).toHaveBeenCalledTimes(3); | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AtpAgent } from "@atproto/api"; | |
import { Record } from "@atproto/api/dist/client/types/app/bsky/feed/post"; | |
export class AltTextBot { | |
#agent: AtpAgent = new AtpAgent({ service: 'https://bsky.social' }); | |
#returnPostWithAltlessImages(post: { uri: string; cid: string; value: Record; }) { | |
const images = post.value?.embed?.images || []; | |
const imagesWithoutAlt = images.filter(img => !img.alt); | |
return { post, imagesWithoutAlt }; | |
} | |
async checkSinglePost(postUri: string) { | |
try { | |
const post = await this.#agent.getPost(await parsePostUri(postUri, this.#agent)); | |
return this.#returnPostWithAltlessImages(post); | |
} catch (e) { | |
return e; | |
} | |
} | |
async login(handle: string, password: string): Promise<void> { | |
await this.#agent.login({ | |
identifier: handle, | |
password: password | |
}); | |
} | |
async streamPosts(handle: string, onUpdate: (results: any) => any) { | |
let cursor: string | undefined = undefined; | |
while (true) { | |
try { | |
const result = await this.#agent.getAuthorFeed({ | |
actor: handle, | |
limit: 20, | |
cursor, | |
filter: 'posts_with_media' | |
}); | |
if (!result.data?.feed?.length) { | |
console.log('No more posts'); | |
onUpdate({ done: true }); | |
break; | |
} | |
onUpdate({result: result.data.feed, done: !result.data.cursor}); | |
if (!result.data.cursor) { | |
break; | |
} | |
cursor = result.data.cursor; | |
} catch (e) { | |
await new Promise(res => setTimeout(res, 5000)); | |
} | |
} | |
} | |
} | |
async function parsePostUri(uri: string, agent: AtpAgent): Promise<{ repo: string; collection: string; rkey: string; }> { | |
// Extract handle and post ID | |
const match = uri.match(/profile\/([^/]+)\/post\/([^/]+)/); | |
if (!match) { | |
return { | |
repo: '', | |
collection: '', | |
rkey: '', | |
}; | |
} | |
const [, handle, rkey] = match; | |
// Get the did | |
const { data: { did: repo } } = await agent.resolveHandle({ handle }); | |
// Use the official bsky app | |
const collection = 'app.bsky.feed.post'; | |
return { | |
repo, | |
collection, | |
rkey | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment