node tests/run.mjs40 tests, no Discord required, well under a second.
ℹ tests 40
ℹ pass 40
ℹ fail 0
A Vencord plugin has two failure modes that are invisible until you're staring at Discord wondering why nothing happened:
- A patch that doesn't match. Vencord logs a warning and carries on. The button simply never appears.
- A wrong search query. The API returns 200 with an empty or subtly wrong result set. Nothing errors.
Neither shows up in a typecheck. So both are tested directly: the patch against Discord's actual shipping bundle, and the search engine against mocked responses with asserted request URLs.
tests/run.mjs:
- Bundles
plugin/mediaTab/search.tsandplugin/mediaTab/headerPatch.tswith esbuild (borrowed fromVencord/node_modules) intotests/.build/*.mjs. - Leaves
@webpack/commonand@utils/Loggerunresolved, then rewrites those import specifiers to point attests/stubs/. - Runs
node --testover both test files.
Step 2 is the fiddly part. esbuild's alias option looked like the obvious tool,
but alias beats external, so the stubs got inlined into the bundle — meaning
the test file's __mock object and the one the bundle mutated were two different
objects, and every state-dependent test failed. Keeping the imports external and
rewriting the specifier afterwards makes both sides share one module instance.
The important property: these tests run the real plugin code, not a
reimplementation of it. extractItems, createPaginator, buildQuery and the
retry logic are the same functions that ship.
tests/stubs/webpack-common.mjs provides RestAPI, ChannelStore,
UserStore and SnowflakeUtils, plus a __mock control object:
__mock.responses = [ /* queued response bodies, consumed in order */ ];
__mock.requests // every URL the code requested, in order
__mock.channels // id → channel
__mock.users // id → user
__mock.reset()Queue an error instead of a response with { __throw: someError }.
SnowflakeUtils implements the real Discord epoch arithmetic, so snowflake
round-trips are genuinely verified rather than faked.
- Every field lifted correctly from an image attachment
- Nine classification cases: by
content_type, and by extension whencontent_typeis absent (gif, mp4, webm, mp3, flac, pdf, zip, uppercase JPEG) - Spoilers via both the flag and the
SPOILER_prefix - Multiple attachments in one message get unique ids
gifv→ GIF,video→ video, with hostname extraction- Article embeds become links only when the active tab wants links
- Bare URLs in message text as a fallback…
- …but not duplicating a URL that already produced an embed item
- Stickers: png and gif produce URLs, lottie is skipped
- The channel's NSFW flag propagates
ex/is/hmsignature params survive resizing (dropping them → 403)- Aspect ratio preserved; videos request
format=jpegfor a still frame - Small images are never upscaled
- DMs hit
/channels/{id}/messages/search - Guild channels hit
/guilds/{id}/messages/searchwithchannel_id - Whole-server scope drops
channel_id limit=25,sort_by,sort_order,offset,include_nsfwall correct- Every filter tab maps to the right
hasbuckets - Each
hasvalue is a separate request — the assertion that guards against the OR/AND ambiguity described in DISCORD-SEARCH-API.md contenttrimmed,author_idrepeated, leading dot stripped from the extension,pinned,sort_order=ascsort_by=relevancewhen sorting by relevance- Date filters become
min_id/max_id, verified by decoding the snowflakes back to the original timestamps
Wraps the stubbed RestAPI.get to observe concurrency, then asserts that an
"All" search (five buckets) never has more than one request in flight. That
part is deterministic, and it's what stops the parallel-request behaviour
described in RULES-AND-RISK.md from being reintroduced by
accident.
It also checks wall-clock spacing, but against half the configured gap rather than the full value. The paced queue is module-level state shared with every other test in the file, and timer granularity under load isn't exact, so demanding the full gap made this flaky — it failed roughly one run in five while a build was running. Half the gap still fails loudly if pacing is removed, since unpaced requests land milliseconds apart.
The rest of the suite calls __setRequestGap(0) so it doesn't take minutes;
this test sets a 60ms gap and resets afterwards.
- Offset advances 0 → 25 → 50 …, never exceeds 9975, then switches to
max_idkeyset paging. Walks a mocked 10,000+ result set to get there. - Stops on a short page and issues no further requests
- Five
hasbuckets merge into one correctly date-ordered stream - Oldest-first reverses the merge order
- Items appearing in two buckets are deduplicated
- Results that don't match the active tab are dropped, even when the API returns them
- 429 retries using
retry_after; 202 (guild indexing) retries; 403 does not retry and propagates abort()prevents any requesttotalis the max across buckets, not the sum
Channel recipients, the current user, and authors seen in results, sorted by name.
These run against tests/.cache/web.js, Discord's real bundle:
- The
findstring occurs exactly once in the whole bundle - The match regex hits exactly once inside that module
- The captured
channelId/guildIdare valid, distinct identifiers - The rewritten module is syntactically valid JavaScript — parsed with
esbuild's
transformSync, which parses without evaluating $self.HeaderToolbarreplaced the Fragment, no Fragment left behind, andchildren[0]is still the toolbar variable — the ordering the component depends on
Fetch the bundle first:
node tests/fetch-discord-bundle.mjs # ~11 MB into tests/.cache/Without it these five tests skip rather than fail, so the suite works
offline. run.mjs prints a note when they're skipping.
The regex under test is imported from plugin/mediaTab/headerPatch.ts — the same
module index.tsx uses — so the test cannot drift from the shipping patch. It
also expands \i to [A-Za-z_$][\w$]* exactly as Vencord's patcher does.
node tests/run.mjs # everything
node --test tests/search.test.mjs # search only (after run.mjs once)
node --test --test-name-pattern="keyset" tests/ # by namerun.mjs must run at least once first to produce tests/.build/.
The React layer in MediaBrowser.tsx has no tests. Testing it would mean
standing up React plus mocks for Modal, Select, SearchableSelect, Tooltip
and openMediaModal — a large amount of harness for components whose behaviour
is mostly "render what search.ts returned". The logic worth protecting (queries,
pagination, extraction, the patch) lives outside it deliberately, and that part
is covered.
Also untested by necessity: whether Discord's live API accepts the queries. Requires a real token. The parameters are verified against Discord's own client code instead, which is the next best thing.
Extraction and query tests go in tests/search.test.mjs. The helpers at the top
of the file cover most needs:
msg({ ... }) // a search-result message
attachment({ ... }) // an attachment
page([msg1, msg2], 42) // a response with total_results = 42
fullPage(startId, ts) // 25 messages, for pagination teststest("what it should do", async () => {
__mock.reset();
__mock.responses = [page([msg({ attachments: [attachment()] })])];
const pager = createPaginator("chan1", null, { ...baseFilters, filter: Filter.Images });
const items = await pager.next(10);
assert.equal(items.length, 1);
assert.match(__mock.requests[0], /has=image/);
});Note that the enum values are duplicated at the top of the test file as plain
objects. types.ts uses const enum, which esbuild inlines, so the enums aren't
importable from the bundle. If you change an enum value, update both.
node tests/run.mjs # 40 tests
cd Vencord && pnpm testTsc # typecheck
cd Vencord && pnpm lint # eslint, including the header rule
cd Vencord && npx stylelint "src/userplugins/mediaTab/styles.css"
../scripts/build.sh # real production buildAll five were clean at the time of writing.