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
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:
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user