CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
Server (#4420) - extension_service gains a Discord pattern (server or channel, jump links, ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by the shared artist-url-samples.json. - probe on a Discord URL matches the source by ids under any artist, reports a whole-server source as covering the channel, suggests the artist who owns another source on the same server, and names server/channel via the stored token (best-effort, bounded, no rate-limit waits). - quick-add takes artist_id / artist_name; Discord URLs are stored canonical. Extension (#4421, #4422) - Content script on discord.com; SPA navigation by URL polling (the old pushState patch ran in the isolated world and never fired); stale probes are dropped. - Discord chip opens an Add panel: this channel or the whole server, and the suggested artist / a search / a new name. - Popup: sources show artist, platform and state; a Discord token export is verified by FC and the result shown. Token capture covers ptb/canary. - Pure logic in lib/chip.js and lib/popup-format.js, with specs. CI (#4423) - extension.yml's lane (web-ext lint, vitest, XPI contents) moves into build.yml as extension-test and joins the needs of sign-extension, build-web and build-agent. As a separate workflow it gated nothing: a red extension suite still signed and shipped the XPI (rule 177). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
280 lines
13 KiB
JavaScript
280 lines
13 KiB
JavaScript
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, parseDiscordUrl, PLATFORMS, PLATFORM_ARTIST_PATTERNS } =
|
|
loadLib('platforms.js', [
|
|
'getPlatformFromUrl',
|
|
'isArtistPage',
|
|
'parseDiscordUrl',
|
|
'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')
|
|
})
|
|
|
|
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 pixiv, retired at milestone #406', () => {
|
|
// Retired on the operator's platform-focus decision (rule #171). Same guard
|
|
// as deviantart's below, and for the same reason: an absence nothing asserts
|
|
// is an absence a later edit can quietly undo.
|
|
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/12345')).toBe(null)
|
|
expect(PLATFORMS.pixiv).toBeUndefined()
|
|
expect(PLATFORM_ARTIST_PATTERNS.pixiv).toBeUndefined()
|
|
})
|
|
|
|
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 Discord server and channel pages, not DMs (milestone 429)', () => {
|
|
expect(isArtistPage('https://discord.com/channels/111/222', 'discord')).toBe(true)
|
|
expect(isArtistPage('https://discord.com/channels/111', 'discord')).toBe(true)
|
|
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
|
|
expect(isArtistPage('https://discord.com/channels/@me/222', '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) would be
|
|
// a platform the button never offers, a product choice rather than 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',
|
|
discord: 'https://ptb.discord.com/channels/111/222'
|
|
}
|
|
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 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)
|
|
// Suffix matching lets a platform's infrastructure subdomains belong to
|
|
// it without listing each one. (It was added for pixiv's OAuth hosts,
|
|
// which left with pixiv at milestone #406; the rule itself is general.)
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('the JS<->Py artist-pattern mirror (#3093)', () => {
|
|
// PLATFORM_ARTIST_PATTERNS here and extension_service._PLATFORM_PATTERNS in
|
|
// the backend are two hand-kept copies of one table, and they gate OPPOSITE
|
|
// halves of a single interaction: this copy decides whether the "Add to FC"
|
|
// button appears, the Python copy decides whether the resulting POST is
|
|
// accepted. So JS-looser-than-Py shows a button that 400s, and
|
|
// Py-looser-than-JS never offers a button for a URL the backend would take.
|
|
// #1485 was the second of those, and its fix had to be applied to both
|
|
// files by hand.
|
|
//
|
|
// "Keep in sync by hand; reviewers catch drift" is the same guarantee
|
|
// manifest.json had before #3069, where deviantart survived seven weeks.
|
|
//
|
|
// The two-runtimes objection to a shared SOURCE file is fair, so the shared
|
|
// artifact is the SAMPLES instead: both suites read this JSON and assert it
|
|
// against their own copy of the patterns, and neither imports the other.
|
|
// The sibling half is tests/test_extension_artist_patterns.py; adding a
|
|
// sample there covers it here for free, and vice versa.
|
|
const samples = Object.fromEntries(
|
|
Object.entries(
|
|
JSON.parse(readFileSync(path.join(EXT_DIR, 'test', 'artist-url-samples.json'), 'utf8'))
|
|
).filter(([key]) => !key.startsWith('$'))
|
|
)
|
|
|
|
for (const [platform, spec] of Object.entries(samples)) {
|
|
// `slug` on a match entry is read by the Python half only — isArtistPage
|
|
// answers a boolean, while the backend's _derive returns (platform, slug).
|
|
for (const { url, why } of spec.match) {
|
|
it(`shows the button on ${url} — ${why}`, () => {
|
|
expect(isArtistPage(url, platform)).toBe(true)
|
|
})
|
|
}
|
|
for (const { url, why } of spec.no_match) {
|
|
it(`hides the button on ${url} — ${why}`, () => {
|
|
expect(isArtistPage(url, platform)).toBe(false)
|
|
})
|
|
}
|
|
}
|
|
|
|
it('has samples for every platform that has an artist pattern', () => {
|
|
// The guard's own coverage check: without it, deleting a platform's
|
|
// samples would make this block pass by testing less. Discord joined at
|
|
// milestone 429 — its slug is server/channel and the artist is chosen.
|
|
expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort())
|
|
})
|
|
|
|
it('has samples in both directions for every platform', () => {
|
|
// A platform with only positive samples pins half the invariant. The
|
|
// no_match half is the one that catches a pattern quietly widening.
|
|
for (const [platform, spec] of Object.entries(samples)) {
|
|
expect(spec.match.length, `${platform} match samples`).toBeGreaterThan(0)
|
|
expect(spec.no_match.length, `${platform} no_match samples`).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('parseDiscordUrl', () => {
|
|
it('reads the server and channel ids the Add panel offers', () => {
|
|
expect(parseDiscordUrl('https://discord.com/channels/111/222')).toEqual({
|
|
serverId: '111',
|
|
channelId: '222'
|
|
})
|
|
expect(parseDiscordUrl('https://discord.com/channels/111/222/333')).toEqual({
|
|
serverId: '111',
|
|
channelId: '222'
|
|
})
|
|
expect(parseDiscordUrl('https://discord.com/channels/111')).toEqual({
|
|
serverId: '111',
|
|
channelId: null
|
|
})
|
|
})
|
|
|
|
it('returns null for anything the artist pattern rejects', () => {
|
|
expect(parseDiscordUrl('https://discord.com/channels/@me/222')).toBe(null)
|
|
expect(parseDiscordUrl('https://discord.com/app')).toBe(null)
|
|
expect(parseDiscordUrl('')).toBe(null)
|
|
})
|
|
})
|