feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
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
This commit is contained in:
2026-09-24 23:31:38 -04:00
co-authored by Claude Opus 5.5
parent 97045279a3
commit 4c75dd0f88
23 changed files with 1176 additions and 209 deletions
+37
View File
@@ -134,5 +134,42 @@
{ "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" },
{ "url": "https://www.hentai-foundry.com/", "why": "site root" }
]
},
"discord": {
"match": [
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "a channel: the slug is server/channel -- a Discord URL names a place, not a creator, so the artist is chosen in the Add panel"
},
{
"url": "https://discord.com/channels/111111111111111111",
"slug": "111111111111111111",
"why": "a whole server"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333",
"slug": "111111111111111111/222222222222222222",
"why": "a message jump link still names its channel"
},
{
"url": "https://ptb.discord.com/channels/111111111111111111/222222222222222222",
"slug": "111111111111111111/222222222222222222",
"why": "the ptb and canary clients serve the same channels"
},
{
"url": "https://discord.com/channels/111111111111111111/222222222222222222/",
"slug": "111111111111111111/222222222222222222",
"why": "trailing slash is tolerated"
}
],
"no_match": [
{ "url": "https://discord.com/channels/@me", "why": "the DM list is not a source" },
{ "url": "https://discord.com/channels/@me/222222222222222222", "why": "a DM is not a source" },
{ "url": "https://discord.com/app", "why": "the app shell, no server open" },
{ "url": "https://discord.com/channels/111111111111111111/222222222222222222/threads/444444444444444444", "why": "thread links are left to the manual Add form" },
{ "url": "https://discord.com/servers/111111111111111111", "why": "a server-discovery page, not a channel" }
]
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [
'chipState',
'chipLabel',
'discordPanelDefaults',
'discordAddRequest'
])
const discord = (extra = {}) => ({
platform: 'discord',
slug: '111/222',
discord: {
server_id: '111',
channel_id: '222',
server_name: 'Studio',
channel_name: 'drops',
server_url: 'https://discord.com/channels/111',
channel_url: 'https://discord.com/channels/111/222'
},
...extra
})
describe('chip state and label', () => {
it('keeps the one-click wording on creator platforms', () => {
expect(chipLabel({ state: 'new', platform: 'patreon' }, 'Patreon')).toBe('+ Add to FabledCurator')
expect(
chipLabel({ state: 'artist_match', platform: 'patreon', artist: { name: 'Atole' } }, 'Patreon')
).toBe('+ Add Patreon source to Atole')
expect(chipLabel({ state: 'source_match', platform: 'patreon' }, 'Patreon')).toBe(
'✓ In FabledCurator · Patreon'
)
})
it('offers the channel by name on Discord, whatever the suggestion', () => {
expect(chipLabel(discord({ state: 'new' }), 'Discord')).toBe('+ Add #drops to FabledCurator')
expect(chipLabel(discord({ state: 'artist_match', artist: { name: 'A' } }), 'Discord')).toBe(
'+ Add #drops to FabledCurator'
)
})
it('says "this channel" when the token could not read the name', () => {
const p = discord({ state: 'new' })
p.discord.channel_name = null
expect(chipLabel(p, 'Discord')).toBe('+ Add this channel to FabledCurator')
})
it('says whose source a Discord channel already is, and when the server covers it', () => {
const artist = { name: 'Tamada', slug: 'tamada' }
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: false }), 'Discord')).toBe(
'✓ In FabledCurator · Tamada'
)
expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: true }), 'Discord')).toBe(
'✓ Whole server in FabledCurator · Tamada'
)
})
it('falls back to the generic add when the probe failed', () => {
expect(chipState({ error: 'x' })).toBe('new')
expect(chipLabel({ error: 'x' }, '')).toBe('+ Add to FabledCurator')
expect(chipState({ state: 'source_match' })).toBe('source-match')
})
})
describe('Discord Add panel', () => {
it('opens on the channel with the suggested artist preselected', () => {
const d = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(d.scope).toBe('channel')
expect(d.artist).toEqual({ id: 7, name: 'Tamada' })
expect(d.artistName).toBe('Tamada')
})
it('proposes a new artist named after the server when nothing is suggested', () => {
const d = discordPanelDefaults(discord({ state: 'new' }))
expect(d.artist).toBe(null)
expect(d.artistName).toBe('Studio')
})
it('sends a picked artist by id', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(discordAddRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistId: 7
})
})
it('sends a typed name once the picked artist has been edited away', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
choice.artistName = 'Tamada Alt'
expect(discordAddRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistName: 'Tamada Alt'
})
})
it('adds the whole server when the operator picks it', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
choice.scope = 'server'
expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111')
})
it('has nothing to send without an artist', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
choice.artistName = ' '
expect(discordAddRequest(choice)).toBe(null)
})
})
+42 -12
View File
@@ -7,10 +7,14 @@ 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']
)
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', () => {
@@ -88,8 +92,11 @@ describe('isArtistPage', () => {
)
})
it('returns false for a platform with no artist pattern (discord)', () => {
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', () => {
@@ -100,8 +107,8 @@ describe('isArtistPage', () => {
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.
// 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)
}
@@ -125,7 +132,8 @@ describe('platform table integrity', () => {
const samples = {
patreon: 'https://www.patreon.com/cw/Atole',
subscribestar: 'https://subscribestar.adult/someone',
hentaifoundry: 'https://www.hentai-foundry.com/user/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)
@@ -152,7 +160,7 @@ describe('manifest.json agrees with the platform table', () => {
)
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.
// 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`
@@ -232,9 +240,8 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
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 is
// deliberately in neither — it is channel-based, with no creator page to
// put a button on, so it has no artist pattern on either side.
// 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())
})
@@ -247,3 +254,26 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => {
}
})
})
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)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { sourceStatus, relativeTime, tokenExportMessage } = loadLib('popup-format.js', [
'sourceStatus',
'relativeTime',
'tokenExportMessage'
])
const NOW = Date.parse('2026-09-25T12:00:00Z')
const src = (extra = {}) => ({ enabled: true, last_error: null, backfill_state: null, ...extra })
describe('sourceStatus', () => {
it('puts an error first, trimmed to its first line', () => {
const s = sourceStatus(src({ last_error: 'Discord rejected the token\nstack…', backfill_state: 'running' }), NOW)
expect(s).toEqual({ text: 'Error — Discord rejected the token', kind: 'error' })
})
it('shows a running backfill and its progress', () => {
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 0 }), NOW).text).toBe('Backfill queued')
expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 3 }), NOW).text).toBe(
'Backfilling — 3 chunks done'
)
})
it('says when a source was last checked, or that it never was', () => {
expect(sourceStatus(src({ last_checked_at: '2026-09-25T11:55:00Z' }), NOW).text).toBe('Checked 5m ago')
expect(sourceStatus(src({ last_checked_at: null }), NOW).text).toBe('Not checked yet')
})
it('shows a disabled source as disabled, whatever else it carries', () => {
expect(sourceStatus(src({ enabled: false, last_error: 'x' }), NOW).text).toBe('Disabled')
})
})
describe('relativeTime', () => {
it('uses the web UI formatRelative buckets', () => {
expect(relativeTime('2026-09-25T11:59:18Z', NOW)).toBe('42s ago')
expect(relativeTime('2026-09-25T09:00:00Z', NOW)).toBe('3h ago')
expect(relativeTime('2026-09-23T12:00:00Z', NOW)).toBe('2d ago')
expect(relativeTime('garbage', NOW)).toBe('Never')
})
})
describe('tokenExportMessage', () => {
it('distinguishes verified, rejected and untested tokens', () => {
expect(tokenExportMessage({ valid: true, reason: 'Token valid (me)' }).kind).toBe('success')
expect(tokenExportMessage({ valid: false, reason: 'Discord rejected the token' }).kind).toBe('error')
const untested = tokenExportMessage({ valid: null, reason: 'No enabled source' })
expect(untested.kind).toBe('warning')
expect(untested.text).toContain('No enabled source')
})
})