Testing a plugin
A plugin reaches the world only through the host it is handed, so the thing to replace in a test is the host. The SDK ships one for that, which is the same double the twelve bundled plugins are tested with.
The fake host
createFakePluginHost comes from @deadair/plugin-sdk/testing, an entry of its own so that nothing a station loads depends on a test runner. It is written for vitest.
import { createFakePluginHost } from '@deadair/plugin-sdk/testing';
const host = createFakePluginHost();
host.seedConfig({ country: 'gb' });
host.queueResponse({ body: JSON.stringify(feed) });
const plugin = new AppleMusicChartsPlugin();
await plugin.init(host);
const entries = await plugin.fetchChart({ chartId: 'most-played', limit: 10 });
expect(host.calls[0]?.url).toBe('https://rss.marketingtools.apple.com/api/v2/gb/music/most-played/100/songs.json');
What it gives you:
queueResponsescripts the nexthost.fetchanswer: a status, headers and a body. Replies are handed out in order, and a fetch with nothing queued throws, so a request you did not expect fails the test.setFetchImplreplaces the queue with a function, for a test that depends on how many calls came before, such as a token refresh after a 401.callsrecords every fetch, with its URL, method, headers and body.seedConfigandseedSecretset whathost.config.get()andhost.secrets.get()answer.seedStorage,getStorageEntryandstorageKeysreach the plugin's storage directly, so a test can arrange state and read it back without going through the code under test.seedRemainingMssets whathost.remainingMs()reports, for a plugin that sheds work when the call is nearly out of time.seedTokensandgetVaultTokensdo the same for OAuth tokens.
The logger, storage and every other host method are vitest mocks, so expect(host.logger.warn).toHaveBeenCalled() works as you would expect.
What to test
The capability's own rules are the ones worth pinning, because they are where a plugin that looks right goes wrong on a station. Each capability's page in the contract lists its own, and two apply to most of them:
- A failure the station can act on is a
PluginErrorwith a code. A 429 israte_limitedwith itsRetry-After, a key the service rejected isauth, and an upstream that is down isunavailable.pluginCodeForStatusanswers the status half of that for you. A bareErroris treated as a fault in the plugin. - An ordinary empty answer is not a failure. A chart id you do not recognise is an empty chart, and a place nobody has heard of is no weather. Where a refusal really is about the thing asked for,
not_found,forbiddenandunsupportedsay so, and the station does not count them against the plugin. Every other failure does, and three in a row take the plugin off the station until it recovers.
Will the station load it
A plugin can build and pass every test and still be quarantined, for a deadair.plugin that points at the wrong file, a library left unbundled, or a manifest the station rejects. The deadair repository has a check that answers with the station's own loader: it installs your built plugin into an empty plugins directory, links the station's SDK and zod beside it as a station does, and runs discovery.
From a checkout of the repository, after pnpm install and pnpm build:
pnpm --filter @deadair/api plugin:prove /path/to/my-plugin --expect com.example.my-charts
It prints what the station would say about the plugin, and exits non-zero unless the plugin was loaded with the id you expected. deadair's own CI runs it on the example plugin for every change, which is how the path an outside plugin takes is kept working.