Skip to content

Latest commit

 

History

History
243 lines (181 loc) · 8.89 KB

TESTING.md

File metadata and controls

243 lines (181 loc) · 8.89 KB

Testing

node tests/run.mjs

40 tests, no Discord required, well under a second.

ℹ tests 40
ℹ pass 40
ℹ fail 0

Why this suite exists

A Vencord plugin has two failure modes that are invisible until you're staring at Discord wondering why nothing happened:

  1. A patch that doesn't match. Vencord logs a warning and carries on. The button simply never appears.
  2. 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.


How the harness works

tests/run.mjs:

  1. Bundles plugin/mediaTab/search.ts and plugin/mediaTab/headerPatch.ts with esbuild (borrowed from Vencord/node_modules) into tests/.build/*.mjs.
  2. Leaves @webpack/common and @utils/Logger unresolved, then rewrites those import specifiers to point at tests/stubs/.
  3. Runs node --test over 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.

The stubs

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.


What's covered

Media extraction — 10 tests

  • Every field lifted correctly from an image attachment
  • Nine classification cases: by content_type, and by extension when content_type is 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

Thumbnails — 3 tests

  • ex / is / hm signature params survive resizing (dropping them → 403)
  • Aspect ratio preserved; videos request format=jpeg for a still frame
  • Small images are never upscaled

Query building — 9 tests

  • DMs hit /channels/{id}/messages/search
  • Guild channels hit /guilds/{id}/messages/search with channel_id
  • Whole-server scope drops channel_id
  • limit=25, sort_by, sort_order, offset, include_nsfw all correct
  • Every filter tab maps to the right has buckets
  • Each has value is a separate request — the assertion that guards against the OR/AND ambiguity described in DISCORD-SEARCH-API.md
  • content trimmed, author_id repeated, leading dot stripped from the extension, pinned, sort_order=asc
  • sort_by=relevance when sorting by relevance
  • Date filters become min_id / max_id, verified by decoding the snowflakes back to the original timestamps

Request pacing — 1 test

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.

Pagination — 8 tests

  • Offset advances 0 → 25 → 50 …, never exceeds 9975, then switches to max_id keyset paging. Walks a mocked 10,000+ result set to get there.
  • Stops on a short page and issues no further requests
  • Five has buckets 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 request
  • total is the max across buckets, not the sum

Participants — 1 test

Channel recipients, the current user, and authors seen in results, sorted by name.

The patch — 5 tests

These run against tests/.cache/web.js, Discord's real bundle:

  1. The find string occurs exactly once in the whole bundle
  2. The match regex hits exactly once inside that module
  3. The captured channelId / guildId are valid, distinct identifiers
  4. The rewritten module is syntactically valid JavaScript — parsed with esbuild's transformSync, which parses without evaluating
  5. $self.HeaderToolbar replaced the Fragment, no Fragment left behind, and children[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.


Running individual tests

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 name

run.mjs must run at least once first to produce tests/.build/.


What isn't covered

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.


Adding a test

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 tests
test("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.


Verifying the whole thing end to end

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 build

All five were clean at the time of writing.