From dad7afc2f1e4d7a61ad10181e9cf668fa250e5c1 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 15 Sep 2026 20:07:49 -0700 Subject: [PATCH] feat: deliver complete Featured lineups for both catalogs --- src/clawhubSearchIntelligence/delivery.ts | 40 ++- src/clawhubSearchIntelligence/evidence.ts | 290 ++++++++++++++++++++- tests/searchIntelligenceApi.test.ts | 304 ++++++++++++++++++++++ 3 files changed, 619 insertions(+), 15 deletions(-) diff --git a/src/clawhubSearchIntelligence/delivery.ts b/src/clawhubSearchIntelligence/delivery.ts index becffe9..7ffcefd 100644 --- a/src/clawhubSearchIntelligence/delivery.ts +++ b/src/clawhubSearchIntelligence/delivery.ts @@ -1,4 +1,9 @@ -import { Routes, type Client, type serializePayload } from "@buape/carbon" +import { + Routes, + TextDisplay, + serializePayload, + type Client +} from "@buape/carbon" import { formSettings } from "../../forms.config.js" import { getRuntimeEnv } from "../runtime/env.js" @@ -37,9 +42,20 @@ const response = (value: unknown, status = 200) => const componentText = (value: unknown): string[] => { if (!value || typeof value !== "object") return [] - const row = value as { content?: unknown; components?: unknown } + const row = value as { + content?: unknown + components?: unknown + type?: unknown + url?: unknown + label?: unknown + } return [ ...(typeof row.content === "string" ? [row.content] : []), + // Full lineups carry candidate identities in link buttons. Reconciliation + // must match those destinations as well as the human-readable text. + ...(row.type === 2 && typeof row.url === "string" + ? [canonical({ url: row.url, label: row.label })] + : []), ...(Array.isArray(row.components) ? row.components.flatMap(componentText) : []) @@ -93,12 +109,30 @@ const findDeliveredMessage = async ( export const deliverWeeklyDigest = async ( client: Client, - digest: { weekStart: number; weekEnd: number; dashboardUrl: string }, + digest: { + kind?: string + weekStart: number + weekEnd: number + dashboardUrl: string + }, body: ReturnType ): Promise => { const db = getRuntimeEnv().DB.withSession("first-primary") const key = `clawhub-search-weekly:${new URL(digest.dashboardUrl).origin}:${digest.weekStart}` const payloadHash = await hash(canonical(digest)) + if (digest.kind === "search_intelligence_weekly_v3") { + // Long candidate URLs may share a dashboard button. The full payload's + // fingerprint preserves exact report identity during uncertain-send recovery. + body = { + ...body, + components: [ + ...(body.components ?? []), + ...(serializePayload({ + components: [new TextDisplay(`-# Report ${payloadHash}`)] + }).components ?? []) + ] + } + } const claim: Delivery = { version: 1, hash: payloadHash, diff --git a/src/clawhubSearchIntelligence/evidence.ts b/src/clawhubSearchIntelligence/evidence.ts index 358053d..62dfba2 100644 --- a/src/clawhubSearchIntelligence/evidence.ts +++ b/src/clawhubSearchIntelligence/evidence.ts @@ -1,4 +1,10 @@ -import { Container, TextDisplay, serializePayload } from "@buape/carbon" +import { + Container, + LinkButton, + Row as ComponentRow, + TextDisplay, + serializePayload +} from "@buape/carbon" import { count, fields, @@ -57,6 +63,22 @@ type Recommendation = { lifetimeInstalls: number | null } } +type LineupRecommendation = Omit & { + version: string | null + support: Recommendation["support"] | "current-only" +} +type FeaturedLineup = { + targetSize: 8 + baseline: { id: string; version: string | null; featuredAt: number }[] + changes: { id: string; change: "retain" | "add"; emerging: boolean }[] + removals: { + id: string + displayName: string + url: string + reasons: string[] + }[] + shortfall: number +} type Catalog = Pick< Digest, | "totalSearches" @@ -93,6 +115,14 @@ export type EvidenceDigest = { truncated: boolean catalogs: { plugins: Catalog; skills: Catalog } } +type LineupCatalog = Omit & { + recommendations: LineupRecommendation[] + lineup: FeaturedLineup +} +type LineupDigest = Omit & { + kind: "search_intelligence_weekly_v3" + catalogs: { plugins: LineupCatalog; skills: LineupCatalog } +} const scope = (value: unknown) => value === "catalog" || value === "shelf" || value === "legacy" const nullable = (value: unknown, validate: (value: unknown) => boolean) => @@ -130,7 +160,8 @@ const validRecommendation = ( origins: string[], weekStart: number, weekEnd: number, - totalSearches: number + totalSearches: number, + fullLineup = false ) => { if ( !fields(value, [ @@ -142,14 +173,16 @@ const validRecommendation = ( "support", "metadataCheckedAt", "search", - "adoption" + "adoption", + ...(fullLineup ? ["version"] : []) ]) || value.artifactKind !== kind || !string(value.id, 256) || !string(value.displayName, 120) || !validUrl(value.url, origins) || !nullable(value.category, (item) => string(item, 120)) || - !timestamp(value.metadataCheckedAt) + !timestamp(value.metadataCheckedAt) || + (fullLineup && !nullable(value.version, (item) => string(item, 256))) ) return false const search = value.search @@ -254,7 +287,7 @@ const validRecommendation = ( if (value.support === "search-only") return ( record(search) && - (search.matchedSearches7d as number) >= 3 && + (search.matchedSearches7d as number) >= (fullLineup ? 1 : 3) && adoption === null ) if (value.support === "both") @@ -263,13 +296,87 @@ const validRecommendation = ( (search.matchedSearches7d as number) > 0 && adoption !== null ) + if (fullLineup && value.support === "current-only") + return search === null && adoption === null return value.support === "adoption-only" && adoption !== null } +const validLineup = ( + value: unknown, + recommendations: LineupRecommendation[], + origins: string[] +) => { + if ( + !fields(value, [ + "targetSize", + "baseline", + "changes", + "removals", + "shortfall" + ]) || + value.targetSize !== 8 || + value.shortfall !== 8 - recommendations.length || + !Array.isArray(value.baseline) || + value.baseline.length > 100 || + !value.baseline.every( + (entry) => + fields(entry, ["id", "version", "featuredAt"]) && + string(entry.id, 256) && + nullable(entry.version, (item) => string(item, 256)) && + timestamp(entry.featuredAt) + ) || + !Array.isArray(value.changes) || + value.changes.length !== recommendations.length || + !value.changes.every( + (entry) => + fields(entry, ["id", "change", "emerging"]) && + string(entry.id, 256) && + (entry.change === "retain" || entry.change === "add") && + typeof entry.emerging === "boolean" + ) || + !Array.isArray(value.removals) || + value.removals.length > 100 || + !value.removals.every( + (entry) => + fields(entry, ["id", "displayName", "url", "reasons"]) && + string(entry.id, 256) && + string(entry.displayName, 120) && + validUrl(entry.url, origins) && + Array.isArray(entry.reasons) && + entry.reasons.length > 0 && + entry.reasons.length <= 12 && + entry.reasons.every((reason) => string(reason, 256)) + ) + ) + return false + const lineup = value as unknown as FeaturedLineup + const baseline = new Set(lineup.baseline.map((entry) => entry.id)) + const selected = new Set(recommendations.map((entry) => entry.id)) + const removed = new Set(lineup.removals.map((entry) => entry.id)) + return ( + baseline.size === lineup.baseline.length && + removed.size === lineup.removals.length && + lineup.changes.every( + (entry, index) => + entry.id === recommendations[index].id && + entry.change === (baseline.has(entry.id) ? "retain" : "add") + ) && + lineup.removals.every( + (entry) => baseline.has(entry.id) && !selected.has(entry.id) + ) && + lineup.baseline.every( + (entry) => selected.has(entry.id) || removed.has(entry.id) + ) && + recommendations.every( + (entry) => entry.support !== "current-only" || baseline.has(entry.id) + ) + ) +} + export const parseEvidenceDigest = ( value: unknown, origins: string[] -): EvidenceDigest | null => { +): EvidenceDigest | LineupDigest | null => { if ( !fields(value, [ "kind", @@ -280,11 +387,13 @@ export const parseEvidenceDigest = ( "truncated", "catalogs" ]) || - value.kind !== "search_intelligence_weekly_v2" || + (value.kind !== "search_intelligence_weekly_v2" && + value.kind !== "search_intelligence_weekly_v3") || !fields(value.catalogs, ["plugins", "skills"]) || new TextEncoder().encode(JSON.stringify(value)).byteLength > 30_000 ) return null + const fullLineup = value.kind === "search_intelligence_weekly_v3" for (const [name, kind] of [ ["plugins", "plugin"], ["skills", "skill"] @@ -301,7 +410,8 @@ export const parseEvidenceDigest = ( "companyOpportunities", "officialGaps", "movers", - "recommendations" + "recommendations", + ...(fullLineup ? ["lineup"] : []) ]) || !validAdoptionSummary(catalog.adoption) ) @@ -349,7 +459,7 @@ export const parseEvidenceDigest = ( if ( !summary || !Array.isArray(catalog.recommendations) || - catalog.recommendations.length > 5 || + catalog.recommendations.length > (fullLineup ? 8 : 5) || !catalog.recommendations.every((candidate) => validRecommendation( candidate, @@ -357,7 +467,8 @@ export const parseEvidenceDigest = ( origins, summary.weekStart, summary.weekEnd, - summary.totalSearches + summary.totalSearches, + fullLineup ) ) || new Set(catalog.recommendations.map((candidate) => candidate.id)).size !== @@ -368,8 +479,17 @@ export const parseEvidenceDigest = ( )) ) return null + if ( + fullLineup && + !validLineup( + catalog.lineup, + catalog.recommendations as LineupRecommendation[], + origins + ) + ) + return null } - return value as unknown as EvidenceDigest + return value as unknown as EvidenceDigest | LineupDigest } const safe = (value: string) => @@ -419,7 +539,153 @@ const recommendationText = (row: Recommendation) => { ].join("\n") } -export const renderEvidenceDigest = (digest: EvidenceDigest) => { +class CandidateLink extends LinkButton { + constructor( + public label: string, + public url: string + ) { + super() + } +} + +const compactName = (value: string) => + safe(value.length > 24 ? `${value.slice(0, 23)}…` : value) +const compactCount = (value: number | null | undefined) => + value == null ? "?" : String(value) + +const renderLineupDigest = (digest: LineupDigest) => { + const preview = ["localhost", "127.0.0.1", "[::1]"].includes( + new URL(digest.dashboardUrl).hostname + ) + // Discord link buttons cap URLs at 512 characters. Keep oversized links + // accessible through the canonical report instead of sending a rejected message. + const dashboardUrl = + digest.dashboardUrl.length <= 512 + ? digest.dashboardUrl + : new URL( + `/management?view=search-insights&endDay=${digest.weekEnd}`, + digest.dashboardUrl + ).href + const components: (Container | TextDisplay)[] = [ + new Container([ + new TextDisplay( + `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub Featured lineups\n${time(digest.weekStart)} – ${time(digest.weekEnd)} UTC. Advisory; approval required.` + ), + new ComponentRow([ + new CandidateLink("Review full evidence and changes", dashboardUrl) + ]) + ]) + ] + const supplements: { title: string; rows: string[]; omitted: boolean }[] = [] + for (const [name, catalog] of [ + ["Plugins", digest.catalogs.plugins], + ["Skills", digest.catalogs.skills] + ] as const) { + const { lineup, recommendations, coverage, adoption } = catalog + const incomplete = + coverage.dataThrough === null || + coverage.dataThrough < digest.weekEnd || + coverage.collectionStartedAt === null || + coverage.collectionStartedAt > digest.weekStart || + coverage.gapStart !== null + const entries = recommendations.map((candidate, index) => { + const change = lineup.changes[index] + return `${index + 1}. **${compactName(candidate.displayName)}** · ${change.change === "retain" ? "Keep" : "Add"}${change.emerging ? " · Emerging" : ""}\n${candidate.support === "current-only" ? "Current selection; window evidence unavailable." : `${compactCount(candidate.search?.matchedSearches7d)} searches · ${candidate.adoption ? adoptionMetrics(candidate.adoption) : "Adoption counts unavailable"}`}` + }) + const rows: (TextDisplay | ComponentRow)[] = [ + new TextDisplay( + [ + `**${name}: ${recommendations.length}/8** · ${lineup.removals.length} proposed removals${lineup.shortfall ? ` · ${lineup.shortfall} unfilled` : ""}`, + `Searches ${catalog.totalSearches}; through ${time(coverage.dataThrough)}.${incomplete ? " Incomplete history." : ""}`, + `Adoption ${time(adoption.periodStart)} – ${time(adoption.periodEnd)}; snapshot ${time(adoption.generatedAt)}${adoption.truncated ? " (capped)" : ""}.`, + ...entries, + ...(!entries.length ? ["No qualifying recommendations."] : []) + ].join("\n") + ) + ] + // Link buttons retain every selected identity without spending Discord's + // text budget on URLs. Four per row keeps both eight-item catalogs visible. + for (let offset = 0; offset < recommendations.length; offset += 4) + rows.push( + new ComponentRow( + recommendations + .slice(offset, offset + 4) + .map( + (candidate, index) => + new CandidateLink( + `${offset + index + 1}. ${candidate.displayName.slice(0, 32)}`, + new URL(candidate.url).href.length <= 512 + ? new URL(candidate.url).href + : dashboardUrl + ) + ) + ) + ) + components.push(new Container(rows)) + for (const [title, facts] of [ + ["company opportunities", catalog.companyOpportunities], + ["official gaps", catalog.officialGaps], + ["movers", catalog.movers] + ] as const) + supplements.push({ + title: `${name} ${title}`, + rows: facts.map( + (row) => + `${compactName(row.query)} (${row.scope}): ${row.searches} searches · ${row.officialGaps} gaps · previous ${row.previousSearches}` + ), + omitted: false + }) + } + components.push( + new TextDisplay( + "? = unavailable, not zero. Human quality, security and category-coverage review required. Full evidence and removal reasons are on the dashboard. Long links open the dashboard." + + (digest.truncated + ? " Evidence details compacted; all selections retained." + : "") + ) + ) + const supplementaryText = () => + supplements + .map((section) => + [ + `**${section.title}**`, + ...section.rows, + ...(section.omitted + ? ["More on the dashboard."] + : section.rows.length + ? [] + : ["None qualified."]) + ].join("\n") + ) + .join("\n") + const primaryLength = components + .flatMap((component) => + component instanceof TextDisplay + ? [component.content ?? ""] + : component.components + .filter( + (child): child is TextDisplay => child instanceof TextDisplay + ) + .map((child) => child.content ?? "") + ) + .join("\n").length + // Only auxiliary rows are compacted; the sixteen candidate identities remain. + // Reserve space for the delivery owner’s immutable report fingerprint. + while (primaryLength + supplementaryText().length > 3800) { + const longest = supplements + .filter((section) => section.rows.length) + .sort((a, b) => b.rows.join("\n").length - a.rows.join("\n").length)[0] + if (!longest) break + longest.rows.pop() + longest.omitted = true + } + components.push(new TextDisplay(supplementaryText())) + return serializePayload({ components, allowedMentions: { parse: [] } }) +} + +export const renderEvidenceDigest = (digest: EvidenceDigest | LineupDigest) => { + if (digest.kind === "search_intelligence_weekly_v3") + return renderLineupDigest(digest) const preview = ["localhost", "127.0.0.1", "[::1]"].includes( new URL(digest.dashboardUrl).hostname ) diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index 9a84664..4da1194 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -177,6 +177,79 @@ const evidencePayload = () => { } } } +const lineupPayload = () => { + const base = evidencePayload() + const catalog = ( + source: typeof base.catalogs.plugins | typeof base.catalogs.skills + ) => { + const row = source.recommendations[0] + const recommendations = Array.from({ length: 8 }, (_, index) => ({ + ...row, + id: `${row.id}-${index}`, + displayName: `Discovery ${row.artifactKind} ${index}`, + url: `${row.url}-${index}`, + version: "1.0.0", + support: index === 0 ? "current-only" : row.support, + search: index === 0 ? null : row.search, + adoption: index === 0 ? null : row.adoption + })) + return { + ...source, + recommendations, + lineup: { + targetSize: 8, + baseline: [ + ...recommendations.slice(0, 2).map((entry) => ({ + id: entry.id, + version: entry.version, + featuredAt: 1 + })), + { id: `${row.id}-old`, version: "0.9.0", featuredAt: 1 } + ], + changes: recommendations.map((entry, index) => ({ + id: entry.id, + change: index < 2 ? "retain" : "add", + emerging: index === 7 + })), + removals: [ + { + id: `${row.id}-old`, + displayName: "Previous selection", + url: `${row.url}-old`, + reasons: ["outside-proposed-set"] + } + ], + shortfall: 0 + } + } + } + return { + ...base, + kind: "search_intelligence_weekly_v3", + catalogs: { + plugins: catalog(base.catalogs.plugins), + skills: catalog(base.catalogs.skills) + } + } +} +const links = (value: unknown): string[] => { + if (!value || typeof value !== "object") return [] + const row = value as { url?: string; components?: unknown[] } + return [ + ...(row.url ? [row.url] : []), + ...(row.components ?? []).flatMap(links) + ] +} +const componentCount = (value: unknown): number => { + if (!value || typeof value !== "object") return 0 + return ( + 1 + + ((value as { components?: unknown[] }).components ?? []).reduce( + (total, child) => total + componentCount(child), + 0 + ) + ) +} let mockClock: ReturnType> | undefined const owners: SqliteD1Database[] = [] const setup = () => { @@ -238,6 +311,237 @@ afterEach(() => { }) describe("ClawHub weekly search intelligence receiver", () => { + it("delivers and replays the complete eight-per-catalog lineup without dropping long links", async () => { + const { client, posts } = setup() + const payload = lineupPayload() + for (const catalog of Object.values(payload.catalogs)) + for (const row of catalog.recommendations) + row.url += "?detail=" + "x".repeat(500) + const before = JSON.stringify(payload) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts).toHaveLength(1) + const components = posts[0].body.components as unknown[] + const text = components.flatMap(texts).join("\n") + expect(text.length).toBeLessThanOrEqual(3900) + expect( + components.reduce((total, row) => total + componentCount(row), 0) + ).toBeLessThanOrEqual(40) + expect(components.flatMap(links)).toHaveLength(17) + expect(components.flatMap(links).every((url) => url.length <= 512)).toBe( + true + ) + expect(text).toContain("Long links open the dashboard") + for (const catalog of Object.values(payload.catalogs)) + for (const row of catalog.recommendations) + expect(text).toContain(row.displayName) + for (const expected of [ + "Plugins: 8/8", + "Skills: 8/8", + "Keep", + "Add", + "Emerging", + "1 proposed removals", + "window evidence unavailable", + "341 downloads" + ]) + expect(text).toContain(expected) + expect(text).toContain("notion") + expect(JSON.stringify(payload)).toBe(before) + expect(posts[0].body.allowed_mentions).toEqual({ parse: [] }) + }) + it("shows bookmarks when they support an adoption-only recommendation", async () => { + const { client, posts } = setup() + const payload = lineupPayload() + const row = payload.catalogs.skills.recommendations[2] + row.adoption = { + ...row.adoption!, + downloads: 0, + installs: 0, + bookmarks: 11 + } + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect( + (posts[0].body.components as unknown[]).flatMap(texts).join("\n") + ).toContain("11 bookmarks") + }) + it("preserves all sixteen selections within the message budget at contract bounds", async () => { + const { client, posts } = setup() + const payload = lineupPayload() + for (const catalog of Object.values(payload.catalogs)) { + catalog.adoption.truncated = true + catalog.totalSearches = Number.MAX_SAFE_INTEGER + catalog.sourceCounts = { + clawhubWeb: Number.MAX_SAFE_INTEGER, + openclawControlUi: 0 + } + for (const row of catalog.recommendations) { + row.displayName = "_".repeat(120) + row.support = "both" + row.search = { + ...evidencePayload().catalogs.plugins.recommendations[0].search + } + row.adoption = { + ...evidencePayload().catalogs[ + row.artifactKind === "plugin" ? "plugins" : "skills" + ].recommendations[0].adoption + } + if (row.search) { + row.search = { + ...row.search, + matchedSearches7d: Number.MAX_SAFE_INTEGER, + previous7d: 0, + searches30d: Number.MAX_SAFE_INTEGER, + queries: [], + omittedQueries: 1 + } + } + if (row.adoption) { + row.adoption.downloads = Number.MAX_SAFE_INTEGER + row.adoption.installs = Number.MAX_SAFE_INTEGER + } + } + for (const change of catalog.lineup.changes) change.emerging = true + } + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + const components = posts[0].body.components as unknown[] + expect(components.flatMap(texts).join("\n").length).toBeLessThanOrEqual( + 3900 + ) + expect(components.flatMap(links)).toHaveLength(17) + }) + it("validates complete lineup membership and privacy before touching delivery state", async () => { + const { client, posts, owner } = setup() + const edits: ((payload: ReturnType) => void)[] = [ + (payload) => { + payload.catalogs.plugins.lineup.shortfall = 1 + }, + (payload) => { + payload.catalogs.plugins.lineup.changes[0].change = "add" + }, + (payload) => { + payload.catalogs.plugins.lineup.removals = [] + }, + (payload) => { + payload.catalogs.plugins.lineup.baseline.push( + payload.catalogs.plugins.lineup.baseline[0] + ) + }, + (payload) => { + payload.catalogs.skills.recommendations.push({ + ...payload.catalogs.skills.recommendations[7], + id: "ninth" + }) + }, + (payload) => { + Object.assign(payload.catalogs.skills.lineup, { userId: "private" }) + }, + (payload) => { + payload.catalogs.plugins.recommendations[1].search!.queries[0].searches7d = 2 + }, + (payload) => { + payload.catalogs.skills.recommendations[2].support = "current-only" + payload.catalogs.skills.recommendations[2].adoption = null + }, + (payload) => { + payload.catalogs.plugins.lineup.removals[0].url = "https://evil.example" + } + ] + for (const edit of edits) { + const payload = lineupPayload() + edit(payload) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(400) + } + expect(posts).toHaveLength(0) + expect( + owner.database.query("SELECT count(*) AS count FROM keyValue").get() + ).toEqual({ count: 0 }) + }) + it("keeps a full-set candidate with rare search counts while suppressing its query text", async () => { + const { client, posts } = setup() + const payload = lineupPayload() + const row = payload.catalogs.plugins.recommendations[2] + row.support = "search-only" + row.adoption = null + row.search = { + ...row.search!, + matchedSearches7d: 1, + previous7d: 0, + searches30d: 1, + queries: [], + omittedQueries: 1 + } + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(JSON.stringify(posts[0].body)).toContain("1 searches") + }) + it("requires candidate link identity as well as text when reconciling a lost full-lineup response", async () => { + const { client, posts } = setup() + const payload = lineupPayload() + const original = client.rest.post.bind(client.rest) + client.rest.post = (async (...args: Parameters) => { + await original(...args) + throw new Error("response lost") + }) as typeof client.rest.post + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + const altered = JSON.parse( + JSON.stringify(posts[0].body.components).replace( + payload.catalogs.plugins.recommendations[0].url, + "https://clawhub.ai/plugins/other" + ) + ) + const history = (components: unknown) => [ + { + id: "message-1", + author: { id: "bot-user", bot: true }, + timestamp: new Date().toISOString(), + components + } + ] + client.rest.get = async () => history(altered) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + const wrongReport = JSON.parse( + JSON.stringify(posts[0].body.components).replace( + /Report [a-f0-9]{64}/, + `Report ${"0".repeat(64)}` + ) + ) + client.rest.get = async () => history(wrongReport) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(503) + client.rest.get = async () => history(posts[0].body.components) + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client)) + ?.status + ).toBe(200) + expect(posts).toHaveLength(1) + }) + it("renders separate catalog evidence, including adoption-supported skills with no searches", async () => { const { client, posts } = setup() expect(