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
+171
View File
@@ -355,6 +355,177 @@ async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db,
assert artist_count == 1 # no duplicate created
# --- Discord: channels are added to a CHOSEN artist (milestone 429) ---
_GUILD = "111111111111111111"
_CHAN = "222222222222222222"
_OTHER_CHAN = "333333333333333333"
async def _discord_source(db, name, url):
artist = Artist(name=name, slug=name.lower(), is_subscription=True)
db.add(artist)
await db.flush()
src = Source(artist_id=artist.id, platform="discord", url=url,
enabled=True, config_overrides={})
db.add(src)
await db.commit()
return artist, src
@pytest.mark.asyncio
async def test_a_discord_channel_is_added_to_the_artist_the_operator_picked(
client, ext_key, db, db_sync,
):
artist = Artist(name="Tamada", slug="tamada", is_subscription=True)
db.add(artist)
await db.commit()
resp = await client.post(
"/api/extension/quick-add-source",
# A jump link on the ptb host still names the channel.
json={"url": f"https://ptb.discord.com/channels/{_GUILD}/{_CHAN}/999",
"artist_id": artist.id},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201, await resp.get_json()
body = await resp.get_json()
assert body["artist"]["id"] == artist.id
assert body["created_artist"] is False
# Stored canonical, so the manual form and the ingester read the same URL.
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
assert body["source"]["platform"] == "discord"
@pytest.mark.asyncio
async def test_a_discord_add_can_name_a_new_artist(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}", "artist_name": "Studio Q"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201
body = await resp.get_json()
assert body["artist"]["name"] == "Studio Q"
assert body["created_artist"] is True
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}"
@pytest.mark.asyncio
async def test_a_discord_add_with_no_artist_and_no_token_names_the_server(client, ext_key):
"""No stored token means no server name to read; the fallback still names
a readable artist rather than slugifying the ids."""
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 201
assert (await resp.get_json())["artist"]["name"] == f"Discord {_GUILD}"
@pytest.mark.asyncio
async def test_re_adding_a_discord_channel_keeps_its_artist(client, ext_key, db):
"""Identity by source (#130), compared by ids: a row stored with a
trailing slash is the same channel, and naming another artist does not
move it."""
owner, src = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}/",
)
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}",
"artist_name": "Someone Else"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["source"]["id"] == src.id
assert body["artist"]["id"] == owner.id
@pytest.mark.asyncio
async def test_quick_add_with_a_missing_artist_id_is_404(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": 987654},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_quick_add_rejects_a_non_integer_artist_id(client, ext_key):
resp = await client.post(
"/api/extension/quick-add-source",
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": "7"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 400
async def _probe(client, ext_key, url):
resp = await client.get(
"/api/extension/probe", query_string={"url": url},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
return await resp.get_json()
@pytest.mark.asyncio
async def test_probe_a_fresh_discord_channel_is_new_with_both_urls(client, ext_key):
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "new"
assert body["platform"] == "discord"
d = body["discord"]
assert d["server_id"] == _GUILD and d["channel_id"] == _CHAN
assert d["server_url"] == f"https://discord.com/channels/{_GUILD}"
assert d["channel_url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
# No token stored → no names, and no error.
assert d["server_name"] is None and d["channel_name"] is None
@pytest.mark.asyncio
async def test_probe_suggests_the_artist_who_owns_another_channel_on_the_server(
client, ext_key, db,
):
owner, _ = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_OTHER_CHAN}",
)
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "artist_match"
assert body["artist"]["id"] == owner.id
assert "source" not in body
@pytest.mark.asyncio
async def test_probe_finds_the_channel_under_any_artist(client, ext_key, db):
owner, src = await _discord_source(
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}",
)
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}/555")
assert body["state"] == "source_match"
assert body["source"]["id"] == src.id
assert body["artist"]["id"] == owner.id
assert body["covered_by_server"] is False
@pytest.mark.asyncio
async def test_probe_a_channel_is_covered_by_a_whole_server_source(client, ext_key, db):
_, src = await _discord_source(db, "Owner", f"https://discord.com/channels/{_GUILD}")
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
assert body["state"] == "source_match"
assert body["source"]["id"] == src.id
assert body["covered_by_server"] is True
@pytest.mark.asyncio
async def test_probe_a_discord_dm_is_not_a_source(client, ext_key):
body = await _probe(client, ext_key, f"https://discord.com/channels/@me/{_CHAN}")
assert body["state"] == "unknown_platform"
# --- /api/extension/manifest ---------------------------------------
+2 -3
View File
@@ -104,9 +104,8 @@ def test_the_sample_table_covers_every_platform_that_has_a_pattern():
samples would make this file pass by testing less — the failure mode that
makes absence-based tests untrustworthy (snippet #3352).
Discord is deliberately absent from both: it has no artist pattern on
either side, because it is channel-based and has no creator page to put a
button on.
Discord is in the table since milestone 429: its "slug" is the
server/channel pair, and the Add panel picks the artist.
"""
from backend.app.services.extension_service import _PLATFORM_PATTERNS