From 6b734845278b96f3c59f074caf3bc2f45f2e7a5c Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 12 Aug 2026 06:11:35 -0400 Subject: [PATCH] Add constellation integration for plugins --- impro-plugin/docs/docs.md | 47 ++++ impro-plugin/main.d.ts | 28 ++ impro-plugin/main.js | 20 ++ impro-plugin/package.json | 2 +- package.json | 2 +- plugins.md | 1 + src/js/app.js | 4 + src/js/atproto.js | 21 ++ src/js/constellation.js | 13 +- src/js/dataLayer/dataLayer.js | 2 + src/js/dataLayer/requests.js | 5 +- src/js/plugins/pluginService.js | 18 +- src/js/slingshot.js | 22 +- tests/unit/specs/constellation.test.js | 73 +++++ tests/unit/specs/dataLayer/dataLayer.test.js | 2 + tests/unit/specs/dataLayer/requests.test.js | 6 +- .../unit/specs/plugins/pluginService.test.js | 252 +++++++++++------- tests/unit/testHelpers.js | 3 + 18 files changed, 391 insertions(+), 130 deletions(-) diff --git a/impro-plugin/docs/docs.md b/impro-plugin/docs/docs.md index 05fbc0e4..3140af25 100644 --- a/impro-plugin/docs/docs.md +++ b/impro-plugin/docs/docs.md @@ -1151,6 +1151,32 @@ Reached via [App.data](#property-data) on the plugin's [App](#app) instance. #### Methods +##### getBacklinks() + +> **getBacklinks**(`params`): `Promise`\<[`BacklinkRecord`](#backlinkrecord)[]\> + +Get records that link to `subject`, from a backlink +index of public records. + +`subject` is an AT-URI or a DID; `source` names the linking field as +`:` (e.g. +`"app.bsky.graph.listitem:list"`). The host paginates for you, up to +`limit` records (max 1000 per call — page by making further calls +with a narrower subject). + +###### Parameters + +| Parameter | Type | +| ------ | ------ | +| `params` | \{ `limit?`: `number`; `source`: `string`; `subject`: `string`; \} | +| `params.limit?` | `number` | +| `params.source` | `string` | +| `params.subject` | `string` | + +###### Returns + +`Promise`\<[`BacklinkRecord`](#backlinkrecord)[]\> + ##### getDetailedProfile() > **getDetailedProfile**(`did`): `Promise`\<[`DetailedProfileView`](#detailedprofileview)\> @@ -2196,6 +2222,27 @@ A text node in a [VirtualEl](#virtualel) tree. Null/undefined coerce to `""`. ## Type Aliases +### BacklinkRecord + +> **BacklinkRecord** = `object` + +A record that links to a queried subject. + +#### Type Parameters + +| Type Parameter | +| ------ | + +#### Type Declaration + +| Name | Type | +| ------ | ------ | +| `collection` | `string` | +| `did` | `string` | +| `rkey` | `string` | + +*** + ### Cloneable > **Cloneable** = `null` \| `undefined` \| `boolean` \| `number` \| `string` \| [`CloneableArray`](#cloneablearray) \| [`CloneableObject`](#cloneableobject) diff --git a/impro-plugin/main.d.ts b/impro-plugin/main.d.ts index 2dbad89b..fe60f99b 100644 --- a/impro-plugin/main.d.ts +++ b/impro-plugin/main.d.ts @@ -26,6 +26,8 @@ export function flattenForScan(tokens: RichTextToken[]): FlattenedTokens; * Paginated response from `getKnownFollowers`: `{ followers, cursor }`. * @typedef {Record} RepoRecord * A raw repo record: `{ uri, cid, value }`. + * @typedef {{ did: string, collection: string, rkey: string }} BacklinkRecord + * A record that links to a queried subject. * @typedef {Record} FeedItem * A `app.bsky.feed.defs#feedViewPost` (post + reply/repost context). * @typedef {{ $type: string } & Record} RichTextFacetFeature @@ -194,6 +196,24 @@ export class PluginData { * @returns {Promise} */ getRecord(repo: string, collection: string, rkey: string): Promise; + /** + * Get records that link to `subject`, from a backlink + * index of public records. + * + * `subject` is an AT-URI or a DID; `source` names the linking field as + * `:` (e.g. + * `"app.bsky.graph.listitem:list"`). The host paginates for you, up to + * `limit` records (max 1000 per call — page by making further calls + * with a narrower subject). + * + * @param {{ subject: string, source: string, limit?: number }} params + * @returns {Promise} + */ + getBacklinks({ subject, source, limit }: { + subject: string; + source: string; + limit?: number; + }): Promise; } /** * The plugin's handle to the running impro app. Exposed as `this.app` on a @@ -1140,6 +1160,14 @@ export type KnownFollowersResponse = Record; * A raw repo record: `{ uri, cid, value }`. */ export type RepoRecord = Record; +/** + * A record that links to a queried subject. + */ +export type BacklinkRecord = { + did: string; + collection: string; + rkey: string; +}; /** * A `app.bsky.feed.defs#feedViewPost` (post + reply/repost context). */ diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 9cbac807..4ed4f3cb 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -9,6 +9,8 @@ * Paginated response from `getKnownFollowers`: `{ followers, cursor }`. * @typedef {Record} RepoRecord * A raw repo record: `{ uri, cid, value }`. + * @typedef {{ did: string, collection: string, rkey: string }} BacklinkRecord + * A record that links to a queried subject. * @typedef {Record} FeedItem * A `app.bsky.feed.defs#feedViewPost` (post + reply/repost context). * @typedef {{ $type: string } & Record} RichTextFacetFeature @@ -337,6 +339,24 @@ export class PluginData { hostCall("getRecord", { repo, collection, rkey }) ); } + /** + * Get records that link to `subject`, from a backlink + * index of public records. + * + * `subject` is an AT-URI or a DID; `source` names the linking field as + * `:` (e.g. + * `"app.bsky.graph.listitem:list"`). The host paginates for you, up to + * `limit` records (max 1000 per call — page by making further calls + * with a narrower subject). + * + * @param {{ subject: string, source: string, limit?: number }} params + * @returns {Promise} + */ + getBacklinks({ subject, source, limit = 100 }) { + return /** @type {Promise} */ ( + hostCall("getBacklinks", { subject, source, limit }) + ); + } } /** diff --git a/impro-plugin/package.json b/impro-plugin/package.json index f76bd517..dd2c25c5 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.22", + "version": "0.0.23", "type": "module", "main": "main.js", "types": "./main.d.ts", diff --git a/package.json b/package.json index 7f0dd82e..f29a76f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.168", + "version": "0.18.169", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/plugins.md b/plugins.md index dd4307ac..5056eef9 100644 --- a/plugins.md +++ b/plugins.md @@ -39,6 +39,7 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Transform rich text in posts - Make whitelisted network requests (requires permissions) - Read appview data with the current user as the viewer (profiles, posts, etc.) +- Query a backlink index for records that link to a subject (e.g. list members, replies) - Mute, block, or send feed feedback ("show more/less like this") on the user's behalf (requires permissions) - Store private plugin data on a user's account (shared across devices) or in local storage diff --git a/src/js/app.js b/src/js/app.js index b5b497bd..9adceb0d 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -67,6 +67,7 @@ import { } from "/js/appViewConfig.js"; import { PluginService } from "/js/plugins/pluginService.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; +import { Constellation } from "/js/constellation.js"; import { MainLayout } from "/js/mainLayout.js"; async function checkDraftsEnabled() { @@ -102,12 +103,14 @@ export async function main() { const identityResolver = new IdentityResolver(); const draftMediaStore = new DraftMediaStore(); const hiddenFeedItemsStore = new HiddenFeedItemsStore(); + const constellation = new Constellation(); const dataLayer = new DataLayer( api, preferencesProvider, identityResolver, draftMediaStore, hiddenFeedItemsStore, + constellation, ); const router = new Router(); const pluginService = new PluginService( @@ -116,6 +119,7 @@ export async function main() { dataLayer, hiddenFeedItemsStore, router, + constellation, ); // put dataLayer on window for easy access in dev tools window.dataLayer = dataLayer; diff --git a/src/js/atproto.js b/src/js/atproto.js index 62f5997c..66daa019 100644 --- a/src/js/atproto.js +++ b/src/js/atproto.js @@ -83,6 +83,27 @@ export class IdentityResolver { } } +const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; +const NSID_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; +const RKEY_PATTERN = /^[a-zA-Z0-9._~:-]{1,512}$/; + +export function isValidDid(value) { + return typeof value === "string" && DID_PATTERN.test(value); +} + +export function isValidNsid(value) { + return typeof value === "string" && NSID_PATTERN.test(value); +} + +export function isValidRkey(value) { + return ( + typeof value === "string" && + value !== "." && + value !== ".." && + RKEY_PATTERN.test(value) + ); +} + const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"; let lastTimestamp = 0n; diff --git a/src/js/constellation.js b/src/js/constellation.js index e8e230fd..33d9f817 100644 --- a/src/js/constellation.js +++ b/src/js/constellation.js @@ -2,7 +2,7 @@ import { buildQueryString } from "/js/utils.js"; import { CONSTELLATION_URL } from "/js/config.js"; export class Constellation { - async getLinks({ subject, source, limit = null, timeout = 10000 }) { + async getLinks({ subject, source, limit = null, timeout = null }) { let cursor = null; const links = []; const controller = new AbortController(); @@ -29,7 +29,18 @@ export class Constellation { signal: controller.signal, }, ); + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error( + `getLinks: ${error?.error ?? `HTTP ${response.status}`} ${ + error?.message ?? "" + }`.trim(), + ); + } const data = await response.json(); + if (!Array.isArray(data?.records)) { + throw new Error("getLinks: malformed response"); + } links.push(...data.records); cursor = data.cursor; } while (cursor && (limit ? links.length < limit : true)); diff --git a/src/js/dataLayer/dataLayer.js b/src/js/dataLayer/dataLayer.js index 976d78eb..e9ea2fc9 100644 --- a/src/js/dataLayer/dataLayer.js +++ b/src/js/dataLayer/dataLayer.js @@ -13,6 +13,7 @@ export class DataLayer extends EventEmitter { identityResolver, draftMediaStore, hiddenFeedItemsStore, + constellation, ) { super(); this.api = api; @@ -29,6 +30,7 @@ export class DataLayer extends EventEmitter { this.preferencesProvider, this.draftMediaStore, this, + constellation, ); this.mutations = new Mutations( this.api, diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index a0bb2c36..724df900 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -16,7 +16,6 @@ import { getPostsFromPostThread, getPostsFromFeed, } from "/js/dataHelpers.js"; -import { Constellation } from "/js/constellation.js"; import { getLocalRefsFromDraft } from "/js/dataHelpers.js"; import { unique } from "/js/utils.js"; import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; @@ -175,14 +174,14 @@ export class Requests { preferencesProvider, draftMediaStore, events, - { constellation } = {}, + constellation, ) { this.api = api; this.events = events; this.dataStore = dataStore; this.preferencesProvider = preferencesProvider; this.draftMediaStore = draftMediaStore; - this.constellation = constellation ?? new Constellation(); + this.constellation = constellation; this.statusStore = new StatusStore(); // Enable status tracking this.enableStatus( diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 7bf8a8d2..1e8b5a1e 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -128,11 +128,13 @@ export class PluginService extends ReactiveStore { dataLayer, hiddenFeedItemsStore, router, + constellation, ) { super("pluginService"); this.renderContext = null; this.router = router; this.slingshot = new Slingshot(); + this.constellation = constellation; this.registries = { sidebarItems: new SignalSet(), eventListeners: new Map(), @@ -519,8 +521,20 @@ export class PluginService extends ReactiveStore { }, ); - this.pluginBridge.addHostMethod("getRecord", (plugin, args) => - this.slingshot.getRecord(args), + this.pluginBridge.addHostMethod( + "getRecord", + (plugin, { repo, collection, rkey }) => + this.slingshot.getRecord({ repo, collection, rkey }), + ); + + this.pluginBridge.addHostMethod( + "getBacklinks", + (plugin, { subject, source, limit }) => { + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new Error(`getBacklinks: invalid limit "${limit}"`); + } + return this.constellation.getLinks({ subject, source, limit }); + }, ); this.pluginBridge.addHostMethod("getCurrentUser", () => { diff --git a/src/js/slingshot.js b/src/js/slingshot.js index e7a00b4c..00b678e1 100644 --- a/src/js/slingshot.js +++ b/src/js/slingshot.js @@ -1,25 +1,5 @@ import { SLINGSHOT_URL } from "/js/config.js"; - -const DID_PATTERN = /^did:(plc|web):[a-zA-Z0-9._%:-]+$/; -const NSID_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; -const RKEY_PATTERN = /^[a-zA-Z0-9._~:-]{1,512}$/; - -function isValidDid(value) { - return typeof value === "string" && DID_PATTERN.test(value); -} - -function isValidNsid(value) { - return typeof value === "string" && NSID_PATTERN.test(value); -} - -function isValidRkey(value) { - return ( - typeof value === "string" && - value !== "." && - value !== ".." && - RKEY_PATTERN.test(value) - ); -} +import { isValidDid, isValidNsid, isValidRkey } from "/js/atproto.js"; export class Slingshot { constructor({ fetchImpl } = {}) { diff --git a/tests/unit/specs/constellation.test.js b/tests/unit/specs/constellation.test.js index 829e0f89..700e077e 100644 --- a/tests/unit/specs/constellation.test.js +++ b/tests/unit/specs/constellation.test.js @@ -157,6 +157,67 @@ describe("constellation", () => { assert.deepEqual(links, []); }); + it("should throw the upstream error name and message on an error response", async () => { + globalThis.fetch.__intercept(BACKLINKS_URL, async () => ({ + ok: false, + status: 400, + json: async () => ({ + error: "InvalidRequest", + message: "invalid source", + }), + })); + + await assert.rejects( + getLinks({ subject: "subj", source: "not-an-nsid" }), + /getLinks: InvalidRequest invalid source/, + ); + }); + + it("should throw the status when an error response has no JSON body", async () => { + globalThis.fetch.__intercept(BACKLINKS_URL, async () => ({ + ok: false, + status: 502, + json: async () => { + throw new Error("not JSON"); + }, + })); + + await assert.rejects( + getLinks({ subject: "subj", source: "src" }), + /getLinks: HTTP 502/, + ); + }); + + it("should throw when a successful response has no records array", async () => { + globalThis.fetch.__interceptJson(BACKLINKS_URL, { cursor: null }); + + await assert.rejects( + getLinks({ subject: "subj", source: "src" }), + /getLinks: malformed response/, + ); + }); + + it("should stop paginating when a later page fails", async () => { + const pages = [ + { records: [{ uri: "a" }], cursor: "cursor1" }, + null, + { records: [{ uri: "b" }], cursor: null }, + ]; + let pageIndex = 0; + globalThis.fetch.__intercept(BACKLINKS_URL, async () => { + const page = pages[pageIndex++]; + return page + ? jsonResponse(page) + : { ok: false, status: 500, json: async () => ({}) }; + }); + + await assert.rejects( + getLinks({ subject: "subj", source: "src" }), + /getLinks: HTTP 500/, + ); + assert.deepEqual(globalThis.fetch.calls.length, 2); + }); + it("should pass an AbortSignal to fetch so the request can be cancelled on timeout", async () => { globalThis.fetch.__interceptJson(BACKLINKS_URL, { records: [], @@ -196,6 +257,18 @@ describe("constellation", () => { assert.deepEqual(globalThis.fetch.calls[0].options.signal.aborted, true); }); + it("should not wire up an abort timer when no timeout is given", async () => { + globalThis.fetch.__interceptJson(BACKLINKS_URL, { + records: [], + cursor: null, + }); + + await getLinks({ subject: "subj", source: "src" }); + + mock.timers.tick(60000); + assert.deepEqual(globalThis.fetch.calls[0].options.signal.aborted, false); + }); + it("should not wire up an abort timer when timeout is 0", async () => { globalThis.fetch.__interceptJson(BACKLINKS_URL, { records: [], diff --git a/tests/unit/specs/dataLayer/dataLayer.test.js b/tests/unit/specs/dataLayer/dataLayer.test.js index 5a486b34..7d6ca1ce 100644 --- a/tests/unit/specs/dataLayer/dataLayer.test.js +++ b/tests/unit/specs/dataLayer/dataLayer.test.js @@ -4,6 +4,7 @@ import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { DraftMediaStore } from "/js/drafts.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; +import { Constellation } from "/js/constellation.js"; function createMockApi(options = {}) { return { @@ -22,6 +23,7 @@ function createDataLayer(api) { { resolveHandle: async () => null }, new DraftMediaStore("test-media"), new HiddenFeedItemsStore(), + new Constellation(), ); } diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index 6e93a35c..932a7bb3 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -16,7 +16,7 @@ function createRequests(api, dataStore, preferencesProvider, events = null) { preferencesProvider, new DraftMediaStore("test-media"), events ?? new EventEmitter(), - { constellation: stubConstellation }, + stubConstellation, ); } @@ -3265,7 +3265,7 @@ function makeRequestsWithConstellation(api, dataStore, constellation) { { requirePreferences: () => Preferences.createLoggedOutPreferences() }, new DraftMediaStore("test-media"), new EventEmitter(), - { constellation }, + constellation, ); } @@ -4094,7 +4094,7 @@ describe("loadDrafts", () => { { requirePreferences: () => Preferences.createLoggedOutPreferences() }, draftMediaStore, new EventEmitter(), - { constellation: stubConstellation }, + stubConstellation, ); } diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 13281492..9b023497 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -7,6 +7,7 @@ import { import { Signal, SignalMap } from "/js/signals.js"; import { EventEmitter } from "/js/eventEmitter.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; +import { Constellation } from "/js/constellation.js"; import { respondToConfirm } from "../../testHelpers.js"; function emptyDataLayer() { @@ -57,6 +58,26 @@ function makeProvider() { }; } +// A real PluginService, wired to a real PluginBridge — for tests that drive +// the bridge's registration targets and host-call handlers directly. +function makeServiceWithRealBridge({ + provider, + session = null, + dataLayer, + hiddenFeedItemsStore, + router = null, + constellation, +} = {}) { + return new PluginService( + provider ?? makeProvider().provider, + session, + dataLayer ?? emptyDataLayer(), + hiddenFeedItemsStore ?? new HiddenFeedItemsStore(), + router, + constellation ?? new Constellation(), + ); +} + // Build a PluginService with its async-heavy dependencies replaced by // inert fakes so we can exercise the install/update orchestration logic // without spinning up sandbox iframes or real fetches. @@ -67,12 +88,7 @@ function makeService({ liveManifestsByRepo = {}, } = {}) { const { state, provider } = makeProvider(); - const service = new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); + const service = makeServiceWithRealBridge({ provider }); const loadCalls = []; const reloadCalls = []; const unloadCalls = []; @@ -811,13 +827,10 @@ describe("$pluginsInfo", () => { it("lists only loaded plugins as previewing, and only in preview mode", () => { const { state, provider } = makeProvider(); - const service = new PluginService( + const service = makeServiceWithRealBridge({ provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - { go: () => {} }, - ); + router: { go: () => {} }, + }); state.installedPlugins = [ { id: "alpha", name: "Alpha", version: "1.0.0", enabled: true }, { id: "beta", name: "Beta", version: "1.0.0", enabled: true }, @@ -1263,15 +1276,12 @@ describe("getFilteredFeedItems", () => { describe("feed filter integration", () => { function makeHarness(getFilteredFeedItems) { - const { provider } = makeProvider(); const dataLayer = emptyDataLayer(); const hiddenFeedItemsStore = new HiddenFeedItemsStore(); - const service = new PluginService( - provider, - null, + const service = makeServiceWithRealBridge({ dataLayer, hiddenFeedItemsStore, - ); + }); service.getFilteredFeedItems = getFilteredFeedItems; return { service, dataLayer, hiddenFeedItemsStore }; } @@ -1344,16 +1354,6 @@ describe("feed filter integration", () => { // The dispatcher's own behavior is covered in pluginRichTextDispatcher.test.js; // these cover the bridge wiring and the facade rich-text elements read through. describe("rich text wiring", () => { - function makeServiceWithRealBridge() { - const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); - } - function registerTransform(service, plugin, message) { return service.pluginBridge._registrationTargets.get("richTextTransform")( plugin, @@ -1435,16 +1435,6 @@ describe("rich text wiring", () => { // The dispatcher's own behavior is covered in pluginSlotDispatcher.test.js; these // cover the bridge wiring and the facade the slot element reads through. describe("slot wiring", () => { - function makeServiceWithRealBridge() { - const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); - } - function registerSlot(service, plugin, message = {}) { return service.pluginBridge._registrationTargets.get("slot")(plugin, { target: "slot", @@ -1520,15 +1510,8 @@ describe("page wiring", () => { return { paths, router: { go: (path) => paths.push(path) } }; } - function makeServiceWithRealBridge(router = makeRecordingRouter().router) { - const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - router, - ); + function makeServiceWithRouter(router = makeRecordingRouter().router) { + return makeServiceWithRealBridge({ router }); } function registerPage(service, plugin, message = {}) { @@ -1546,7 +1529,7 @@ describe("page wiring", () => { } it("exposes a registered page and invokes its display handler", async () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); const calls = []; registerPage( service, @@ -1566,7 +1549,7 @@ describe("page wiring", () => { }); it("keeps pages of the same plugin separate and scoped by plugin id", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin("alpha"), { id: "one", title: "One" }); registerPage(service, makePlugin("alpha"), { id: "two", title: "Two" }); registerPage(service, makePlugin("beta"), { id: "one", title: "Beta One" }); @@ -1577,13 +1560,13 @@ describe("page wiring", () => { }); it("defaults a missing title to null", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin(), { title: undefined }); assert.deepEqual(service.getPage("alpha", "dashboard").title, null); }); it("rejects page ids that aren't URL-safe", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); for (const id of ["Dashboard", "a/b", "a b", "a?b", "", 7, null]) { assert.deepEqual( registerPage(service, makePlugin(), { id }), @@ -1595,7 +1578,7 @@ describe("page wiring", () => { }); it("replaces an earlier registration of the same id", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin(), { title: "First" }); registerPage(service, makePlugin(), { title: "Second" }); assert.deepEqual(service.$pages.size, 1); @@ -1603,7 +1586,7 @@ describe("page wiring", () => { }); it("disposes only its own entry, not a replacement", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); const disposeFirst = registerPage(service, makePlugin(), { title: "First", }); @@ -1613,14 +1596,14 @@ describe("page wiring", () => { }); it("removes the page when its registration is disposed", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); const dispose = registerPage(service, makePlugin()); dispose(); assert.deepEqual(service.getPage("alpha", "dashboard"), null); }); it("bumps the page's customContent refresh signal", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin()); const { customContent } = service.getPage("alpha", "dashboard"); assert.deepEqual(customContent.$refresh.get(), null); @@ -1633,7 +1616,7 @@ describe("page wiring", () => { }); it("defaults refreshPage's reset flag to false and requires a pageId", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin()); const { customContent } = service.getPage("alpha", "dashboard"); const refreshPage = @@ -1645,7 +1628,7 @@ describe("page wiring", () => { }); it("signals a fresh value on every refresh so repeats still notify", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); registerPage(service, makePlugin()); const { customContent } = service.getPage("alpha", "dashboard"); const refreshPage = @@ -1658,7 +1641,7 @@ describe("page wiring", () => { }); it("ignores a refresh for a page that is not registered", () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); const refreshPage = service.pluginBridge._hostCallHandlers.get("refreshPage"); refreshPage({ pluginId: "alpha" }, { pageId: "missing" }); @@ -1666,7 +1649,7 @@ describe("page wiring", () => { it("routes openPage to the calling plugin's own page path", () => { const { paths, router } = makeRecordingRouter(); - const service = makeServiceWithRealBridge(router); + const service = makeServiceWithRouter(router); const openPage = service.pluginBridge._hostCallHandlers.get("openPage"); openPage({ pluginId: "alpha" }, { pageId: "dashboard" }); assert.deepEqual(paths, ["/plugin/alpha/pages/dashboard"]); @@ -1674,7 +1657,7 @@ describe("page wiring", () => { it("encodes the plugin id in the openPage path and requires a pageId", () => { const { paths, router } = makeRecordingRouter(); - const service = makeServiceWithRealBridge(router); + const service = makeServiceWithRouter(router); const openPage = service.pluginBridge._hostCallHandlers.get("openPage"); openPage({ pluginId: "alpha/../beta" }, { pageId: "dashboard" }); assert.deepEqual(paths, ["/plugin/alpha%2F..%2Fbeta/pages/dashboard"]); @@ -1682,7 +1665,7 @@ describe("page wiring", () => { }); it("reports the plugin's load status", async () => { - const service = makeServiceWithRealBridge(); + const service = makeServiceWithRouter(); service.$initialLoadComplete.set(true); assert.deepEqual(service.getPluginLoadStatus("alpha"), { loading: false, @@ -1716,14 +1699,8 @@ describe("app.data host methods", () => { } function makeService(dataLayerOverrides) { - const { provider } = makeProvider(); const dataLayer = Object.assign(emptyDataLayer(), dataLayerOverrides); - return new PluginService( - provider, - null, - dataLayer, - new HiddenFeedItemsStore(), - ); + return makeServiceWithRealBridge({ dataLayer }); } it("getProfile host method returns the hydrated profile from derived", async () => { @@ -1890,12 +1867,7 @@ describe("action host methods", () => { }, }); const session = { did: "did:plc:me", handle: "me.test" }; - const service = new PluginService( - provider, - session, - dataLayer, - new HiddenFeedItemsStore(), - ); + const service = makeServiceWithRealBridge({ provider, session, dataLayer }); return { service, calls }; } @@ -2055,13 +2027,7 @@ describe("action host methods", () => { }); it("all action methods reject when signed out", async () => { - const { provider } = makeProvider(); - const service = new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); + const service = makeServiceWithRealBridge(); const allActionsPlugin = { pluginId: "test-plugin", permissions: { actions: ["mute", "block", "feedFeedback"] }, @@ -2082,16 +2048,6 @@ describe("action host methods", () => { }); describe("getRecord host method", () => { - function makeServiceWithRealBridge() { - const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); - } - function jsonResponse(status, body) { return { ok: status >= 200 && status < 300, @@ -2211,17 +2167,117 @@ describe("getRecord host method", () => { }); }); -describe("loadLocalData/saveLocalData host methods", () => { - function makeServiceWithRealBridge() { +describe("getBacklinks host method", () => { + const SUBJECT = "at://did:plc:test000001/app.bsky.graph.list/3laa"; + const SOURCE = "app.bsky.graph.listitem:list"; + + function makeServiceWithStubbedConstellation() { const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), - ); + const calls = []; + const constellation = { + getLinks: async (args) => { + calls.push(args); + return [ + { did: "did:plc:test000002", collection: SOURCE, rkey: "3lbb" }, + ]; + }, + }; + return { service: makeServiceWithRealBridge({ constellation }), calls }; } + function getHandler(service) { + return service.pluginBridge._hostCallHandlers.get("getBacklinks"); + } + + it("passes validated args through to the constellation client", async () => { + const { service, calls } = makeServiceWithStubbedConstellation(); + const result = await getHandler(service)(null, { + subject: SUBJECT, + source: SOURCE, + limit: 50, + }); + assert.deepEqual(calls, [{ subject: SUBJECT, source: SOURCE, limit: 50 }]); + assert.deepEqual(result.length, 1); + }); + + it("accepts a bare did as the subject", async () => { + const { service, calls } = makeServiceWithStubbedConstellation(); + await getHandler(service)(null, { + subject: "did:plc:test000001", + source: "app.bsky.graph.follow:subject", + limit: 10, + }); + assert.deepEqual(calls[0].subject, "did:plc:test000001"); + }); + + it("ignores a plugin-supplied timeout", async () => { + const { service, calls } = makeServiceWithStubbedConstellation(); + await getHandler(service)(null, { + subject: SUBJECT, + source: SOURCE, + limit: 1000, + timeout: 1, + }); + assert.deepEqual(calls[0], { + subject: SUBJECT, + source: SOURCE, + limit: 1000, + }); + }); + + // A malformed subject/source is the plugin author's problem: it comes back + // as an upstream 400 rather than being second-guessed here. + it("passes through subject/source syntax it doesn't recognize", async () => { + const { service, calls } = makeServiceWithStubbedConstellation(); + await getHandler(service)(null, { + subject: "https://example.com/not-atproto", + source: "not-an-nsid", + limit: 10, + }); + assert.deepEqual(calls[0].subject, "https://example.com/not-atproto"); + assert.deepEqual(calls[0].source, "not-an-nsid"); + }); + + // A limit that isn't a positive integer under the cap would leave the + // host's pagination loop unbounded (missing/null) or silently empty + // (NaN, non-numeric strings), so none of them may reach constellation. + it("rejects a limit that isn't a positive integer within the cap", async () => { + const { service, calls } = makeServiceWithStubbedConstellation(); + const invalidLimits = [ + undefined, + null, + 0, + -1, + 1.5, + 1001, + "10", + "abc", + NaN, + Infinity, + ]; + for (const limit of invalidLimits) { + await assert.rejects( + (async () => + getHandler(service)(null, { + subject: SUBJECT, + source: SOURCE, + limit, + }))(), + /getBacklinks: invalid limit/, + `expected rejection for limit ${limit}`, + ); + } + assert.deepEqual(calls, []); + }); + + it("requires no permissions and no session", () => { + const { service } = makeServiceWithStubbedConstellation(); + assert.deepEqual(service.session, null); + assert(getHandler(service) !== undefined); + }); +}); + +describe("loadLocalData/saveLocalData host methods", () => { function getHandler(service, name) { return service.pluginBridge._hostCallHandlers.get(name); } diff --git a/tests/unit/testHelpers.js b/tests/unit/testHelpers.js index e0c56942..ce898db4 100644 --- a/tests/unit/testHelpers.js +++ b/tests/unit/testHelpers.js @@ -3,6 +3,7 @@ import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; import { DraftMediaStore } from "/js/drafts.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; +import { Constellation } from "/js/constellation.js"; import { Signal, SignalMap } from "/js/signals.js"; export function makeTestDataLayer({ @@ -10,6 +11,7 @@ export function makeTestDataLayer({ identityResolver, draftMediaStore, hiddenFeedItemsStore, + constellation, } = {}) { const api = { getProfile: async () => null, @@ -25,6 +27,7 @@ export function makeTestDataLayer({ identityResolver ?? { resolveHandle: async () => null }, draftMediaStore ?? new DraftMediaStore("test-media"), hiddenFeedItemsStore ?? new HiddenFeedItemsStore(), + constellation ?? new Constellation(), ); } -- 2.51.2