Files
orca/src/shared/repo-icon.test.ts
ce3ec4d5ce fix(repo-icon): keep a renamed fork's own owner avatar (#12271)
* fix(repo-icon): keep a renamed fork's own owner avatar

Fork repos always took the upstream owner's avatar, so a renamed fork
showed its parent project's logo. Same-name forks (personal copies)
still prefer the upstream owner; renamed forks now keep their origin
owner across auto-detect, the startup backfill, and the settings
avatar refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(repo-icon): re-read repo state before backfill avatar write

The startup backfill computed icon updates from a pre-loop snapshot, so
an icon chosen in settings while the upstream/origin probes were pending
could be clobbered. Re-read the repo after the probes and only migrate
an icon that is still the auto-detected GitHub avatar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(repo-icon): own the fork avatar rule in one shared selector

The renamed-fork rule was written out twice — once in the main-process
auto-detect and once in the renderer refresh — so the two copies could
drift. Move it next to `githubAvatarIcon` as `githubAvatarSlug`, which
collapses the renderer resolver to a single unbranched path.

Also stop swallowing a rejected origin probe: it cannot tell a renamed
fork from a same-name one, so degrading to the upstream owner would flip
a renamed fork's stored avatar back to the parent's. Letting it propagate
keeps the stored icon, matching how the non-fork path already behaved.

Adds coverage for the startup backfill, the third decision point the fix
claims, which had none.

* test(repo-icon): cover pending backfill icon change

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-10 01:06:32 -07:00

191 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { githubAvatarIcon, githubAvatarSlug, sanitizeRepoIcon } from './repo-icon'
const PNG_1X1_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='
const WEBP_1X1_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA=='
function pngBase64(width: number, height: number): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(height, 20)
return bytes.toString('base64')
}
describe('sanitizeRepoIcon', () => {
it('accepts lucide, emoji, and supported image icons', () => {
expect(sanitizeRepoIcon({ type: 'lucide', name: 'Folder' })).toEqual({
type: 'lucide',
name: 'Folder'
})
expect(sanitizeRepoIcon({ type: 'emoji', emoji: '🚀' })).toEqual({
type: 'emoji',
emoji: '🚀'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
).toEqual({
type: 'image',
src: 'https://github.com/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://github.acme.test/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
).toEqual({
type: 'image',
src: 'https://github.acme.test/stablyai.png?size=64',
source: 'github',
label: 'stablyai/orca'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
source: 'favicon'
})
).toEqual({
type: 'image',
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
source: 'favicon'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
source: 'upload'
})
).toEqual({
type: 'image',
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
source: 'upload'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
source: 'file'
})
).toEqual({
type: 'image',
src: `data:image/png;base64,${PNG_1X1_BASE64}`,
source: 'file'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/webp;base64,${WEBP_1X1_BASE64}`,
source: 'file'
})
).toEqual({
type: 'image',
src: `data:image/webp;base64,${WEBP_1X1_BASE64}`,
source: 'file'
})
})
it('keeps null as an explicit reset', () => {
expect(sanitizeRepoIcon(null)).toBeNull()
})
it('rejects unsupported image urls and oversized payloads', () => {
expect(
sanitizeRepoIcon({
type: 'image',
src: 'javascript:alert(1)',
source: 'favicon'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/png;base64,${pngBase64(32_769, 1)}`,
source: 'upload'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: `data:image/png;base64,${'a'.repeat(401 * 1024)}`,
source: 'upload'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
source: 'upload'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
source: 'file'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://example.com/nested/icon.png',
source: 'github'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://user@example.com/icon.png',
source: 'github'
})
).toBeUndefined()
})
it('builds hosted avatar URLs only from a valid host value', () => {
expect(
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'GitHub.Acme.Test:8443' })
).toMatchObject({ src: 'https://github.acme.test:8443/acme.png?size=64' })
// Explicit default port 443 is canonical for an HTTPS host: accept it
// (serialized without the port) rather than falling back to github.com.
expect(
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'ghe.example:443' })
).toMatchObject({ src: 'https://ghe.example/acme.png?size=64' })
expect(
githubAvatarIcon({ owner: 'acme', repo: 'widgets', host: 'github.com@evil.example' })
).toMatchObject({ src: 'https://github.com/acme.png?size=64' })
})
})
describe('githubAvatarSlug', () => {
const upstream = { owner: 'upstream-org', repo: 'rocket' }
it('keeps the upstream owner for a same-name fork, case-insensitively', () => {
expect(githubAvatarSlug({ owner: 'acme', repo: 'rocket' }, upstream)).toEqual(upstream)
expect(githubAvatarSlug({ owner: 'acme', repo: 'RocKet' }, upstream)).toEqual(upstream)
})
it('keeps the fork own owner once it has been renamed', () => {
const origin = { owner: 'acme', repo: 'rocket-pro' }
expect(githubAvatarSlug(origin, upstream)).toEqual(origin)
})
it('falls back to whichever identity is known', () => {
const origin = { owner: 'acme', repo: 'rocket-pro' }
expect(githubAvatarSlug(origin, null)).toEqual(origin)
expect(githubAvatarSlug(null, upstream)).toEqual(upstream)
expect(githubAvatarSlug(null, undefined)).toBeNull()
})
})