import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import path from 'node:path' import { loadLib } from './helpers/loadLib.js' const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8')) const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib( 'platforms.js', ['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS'] ) describe('getPlatformFromUrl', () => { it('identifies each platform from a domain URL', () => { expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon') expect(getPlatformFromUrl('https://subscribestar.adult/someone')).toBe('subscribestar') expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry') expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord') expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv') }) it('accepts http as well as https, with or without www', () => { expect(getPlatformFromUrl('http://patreon.com/Atole')).toBe('patreon') expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon') }) it('returns null for unrelated hosts', () => { expect(getPlatformFromUrl('https://example.com/patreon.com')).toBe(null) expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null) expect(getPlatformFromUrl('')).toBe(null) }) it('returns null for deviantart, retired at #3069', () => { // The 2026-07-05 product decision (FC downloaders = art-dedicated services // only) left deviantart wired for seven weeks. Asserting the negative is // what keeps a partial retirement from being re-completed by accident. expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe(null) expect(PLATFORMS.deviantart).toBeUndefined() expect(PLATFORM_ARTIST_PATTERNS.deviantart).toBeUndefined() }) }) describe('isArtistPage', () => { // Regression cases from issue #1485: the Add-to-FC button vanished once the // operator SUBSCRIBED to a creator, because Patreon serves subscribed users // the /cw/ ("creator workspace") URL and the pattern only matched the bare // root. All three creator URL shapes must match, plus inner pages — the // button matters most exactly when you're subscribed. it('matches all three Patreon creator URL shapes', () => { expect(isArtistPage('https://www.patreon.com/Atole', 'patreon')).toBe(true) expect(isArtistPage('https://www.patreon.com/c/Atole', 'patreon')).toBe(true) expect(isArtistPage('https://www.patreon.com/cw/Atole', 'patreon')).toBe(true) }) it('matches Patreon creator inner pages', () => { expect(isArtistPage('https://www.patreon.com/cw/Atole/posts', 'patreon')).toBe(true) expect(isArtistPage('https://www.patreon.com/Atole/membership', 'patreon')).toBe(true) }) it('excludes Patreon navigation pages that are not creators', () => { for (const nav of ['home', 'search', 'messages', 'notifications', 'library', 'settings']) { expect(isArtistPage(`https://www.patreon.com/${nav}`, 'patreon')).toBe(false) expect(isArtistPage(`https://www.patreon.com/${nav}/anything`, 'patreon')).toBe(false) } }) it('matches SubscribeStar creator roots on both TLDs but not feed pages', () => { expect(isArtistPage('https://subscribestar.adult/someone', 'subscribestar')).toBe(true) expect(isArtistPage('https://subscribestar.com/someone', 'subscribestar')).toBe(true) expect(isArtistPage('https://subscribestar.adult/feed', 'subscribestar')).toBe(false) expect(isArtistPage('https://subscribestar.adult/messages', 'subscribestar')).toBe(false) }) it('matches Hentai Foundry user pages only', () => { expect(isArtistPage('https://www.hentai-foundry.com/user/someone', 'hentaifoundry')).toBe(true) expect(isArtistPage('https://www.hentai-foundry.com/pictures/popular', 'hentaifoundry')).toBe( false ) }) it('matches Pixiv numeric user pages, with or without the /en/ prefix', () => { expect(isArtistPage('https://www.pixiv.net/users/12345', 'pixiv')).toBe(true) expect(isArtistPage('https://www.pixiv.net/en/users/12345', 'pixiv')).toBe(true) expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false) }) it('returns false for a platform with no artist pattern (discord)', () => { expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false) }) it('returns false for an unknown platform key', () => { expect(isArtistPage('https://www.patreon.com/Atole', 'nope')).toBe(false) }) }) describe('platform table integrity', () => { it('gives every artist pattern a corresponding platform entry', () => { // A pattern keyed to a platform that no longer exists is dead code that // silently never fires; the reverse (a platform with no pattern) is the // legitimate discord case, so only this direction is an error. for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) { expect(Object.keys(PLATFORMS)).toContain(key) } }) it('gives every platform the fields the popup renders', () => { for (const [key, platform] of Object.entries(PLATFORMS)) { expect(platform.name, `${key}.name`).toBeTruthy() expect(platform.color, `${key}.color`).toMatch(/^#[0-9A-Fa-f]{6}$/) expect(['cookies', 'token'], `${key}.authType`).toContain(platform.authType) expect(platform.urlPattern, `${key}.urlPattern`).toBeInstanceOf(RegExp) expect(Array.isArray(platform.domains), `${key}.domains`).toBe(true) expect(platform.domains.length, `${key}.domains`).toBeGreaterThan(0) } }) it('keeps every artist URL matched by its own platform pattern too', () => { // isArtistPage is only ever consulted after getPlatformFromUrl resolves a // key, so an artist pattern matching a URL its platform's urlPattern // rejects would be unreachable. const samples = { patreon: 'https://www.patreon.com/cw/Atole', subscribestar: 'https://subscribestar.adult/someone', hentaifoundry: 'https://www.hentai-foundry.com/user/someone', pixiv: 'https://www.pixiv.net/en/users/12345' } for (const [key, url] of Object.entries(samples)) { expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true) expect(getPlatformFromUrl(url), `${key} urlPattern`).toBe(key) } }) }) describe('manifest.json agrees with the platform table', () => { // #3069: deviantart was dropped from the product in July but survived in // manifest.json until late August, because NOTHING tied the manifest's // domain lists back to PLATFORMS. These two specs are that tie. Both // directions matter: a stale match ships host access the product decided // not to use, and a missing one silently kills the Add-to-FC button. const matches = manifest.content_scripts[0].matches // '*://*.patreon.com/*' -> '.patreon.com', the form PLATFORMS.domains uses. const hostOf = (m) => m.replace(/^\*:\/\/\*/, '').replace(/\/\*$/, '') it('injects the content script only on domains a platform claims', () => { for (const m of matches) { const host = hostOf(m) const owner = Object.entries(PLATFORMS).find( ([, p]) => p.domains.includes(host) ) expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy() // The content script exists to draw the Add-as-source button, so a // platform with no artist pattern (discord) has no business here. expect( PLATFORM_ARTIST_PATTERNS[owner[0]], `"${m}" injects for ${owner[0]}, which has no artist pattern` ).toBeTruthy() } }) it('injects on every platform that has an artist pattern', () => { const covered = new Set( matches .map(hostOf) .map((h) => Object.entries(PLATFORMS).find(([, p]) => p.domains.includes(h))) .filter(Boolean) .map(([key]) => key) ) for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) { expect(covered, `${key} has an artist pattern but no content-script match`).toContain(key) } }) it('requests no host permission for a domain no platform claims', () => { // '*://*/*' is the deliberate exception: FC is self-hosted at an arbitrary // operator-chosen URL, so the extension cannot enumerate its own backend. // Every OTHER entry is a platform domain and must still have an owner. for (const h of manifest.host_permissions) { if (h === '*://*/*') continue const host = hostOf(h) // pixiv's OAuth/API hosts are pixiv infrastructure, not creator pages, // so they are matched by suffix rather than by the domains list. const claimed = Object.values(PLATFORMS).some( (p) => p.domains.includes(host) || p.domains.some((d) => host.endsWith(d)) ) expect(claimed, `host permission "${h}" belongs to no platform`).toBe(true) } }) })