Files
TREK/server/tests/unit/nest/wiki.test.ts
T
Maurice 4346d7f2c1 feat(help): in-app help center with per-screen guides and pictures
A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
2026-09-24 14:05:42 +02:00

301 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'path';
// The wiki module picks its source (local disk vs GitHub) once at module load, so each
// mode needs a fresh import with TREK_WIKI_DIR already set.
const FIXTURE_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'wiki');
const MISSING_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'no-such-wiki');
type WikiModule = typeof import('../../../src/nest/help/wiki');
async function loadWiki(dir: string | undefined): Promise<WikiModule> {
vi.resetModules();
if (dir === undefined) delete process.env.TREK_WIKI_DIR;
else process.env.TREK_WIKI_DIR = dir;
return import('../../../src/nest/help/wiki');
}
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.TREK_WIKI_DIR;
});
describe('wiki — local wiki on disk', () => {
it('serves the sidebar from disk and never touches the network', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
expect(wiki.isLocalWiki()).toBe(true);
const { sections } = await wiki.getWikiIndex();
expect(sections.map((s) => s.title)).toEqual(['Getting Started', 'Planning']); // empty section dropped
expect(sections[0].pages).toEqual([
{ title: 'Home', slug: 'Home' },
{ title: 'Quick Start', slug: 'Quick-Start' },
]);
// `_private` fails SLUG_RE, so it is filtered out of the nav.
expect(sections[1].pages).toEqual([{ title: 'Sample Page', slug: 'Sample' }]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('renders a page, rewriting wikilinks and relative images', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
expect(page.title).toBe('Sample Page');
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start)');
expect(page.markdown).toContain('[Home](/help/Home)');
expect(page.markdown).toContain('![A picture](/api/help/asset/assets/pic.png)');
expect(page.markdown).toContain('![External](https://example.com/x.png)'); // absolute URLs left alone
expect(page.markdown).not.toContain('TODO: screenshot'); // HTML comments stripped
expect(fetchSpy).not.toHaveBeenCalled();
});
it('rewrites bare relative links, the spelling most wiki pages actually use', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// These resolve against the wiki root on GitHub but used to fall through to
// HelpPage's external-link branch in-app and open a dead tab.
expect(page.markdown).toContain('[Currencies](/help/Currencies)');
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start#first-steps)');
expect(page.markdown).toContain('[with extension](/help/Home)'); // .md stripped
});
it('leaves links that are already resolvable alone', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
expect(page.markdown).toContain('[external](https://example.com)');
expect(page.markdown).toContain('[absolute](/dashboard)');
expect(page.markdown).toContain('[anchor](#section)');
expect(page.markdown).toContain('[mail](mailto:hi@example.com)');
// Images must not be caught by the link rewriter.
expect(page.markdown).toContain('![A picture](/api/help/asset/assets/pic.png)');
expect(page.markdown).not.toContain('/help/assets/pic.png');
});
it('leaves code samples alone', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// Plugin-Development.md documents `actions[key](ctx)`, which reads as a
// markdown link. Rewriting it would corrupt a verbatim snippet.
expect(page.markdown).toContain('`actions[key](ctx)`');
expect(page.markdown).toContain('`[x](y)`');
expect(page.markdown).toContain('const link = [label](Currencies)'); // fenced block
expect(page.markdown).not.toContain('/help/ctx');
expect(page.markdown).not.toContain('[label](/help/Currencies)');
});
it('does not render a wikilink anchor as visible link text', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const page = await wiki.getWikiPage('Sample');
// `[[Quick Start#first-steps|Quick-Start]]` — the anchor belongs in the href.
expect(page.markdown).toContain('[Quick Start](/help/Quick-Start#first-steps)');
expect(page.markdown).not.toContain('[Quick Start#first-steps]');
});
it('404s an unknown page instead of falling back to GitHub', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
// A page missing from disk does not exist in *this* version — falling back to
// GitHub here is what would reintroduce version skew.
await expect(wiki.getWikiPage('Nope')).rejects.toBeInstanceOf(wiki.WikiNotFound);
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each([['_Sidebar'], ['../Home'], ['a/b'], ['']])('rejects the invalid slug %j', async (slug) => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiPage(slug)).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
it('serves an asset from disk with the right content type', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const { buf, type } = await wiki.getWikiAsset('assets/pic.png');
expect(type).toBe('image/png');
expect(buf.subarray(1, 4).toString()).toBe('PNG');
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each([
['../../src/config.ts'],
['../../../.env'],
['assets/../../package.json'],
['/etc/passwd'],
['assets/notes.txt'],
])('refuses the asset path %j', async (assetPath) => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiAsset(assetPath)).rejects.toBeInstanceOf(wiki.WikiNotFound);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('404s a missing asset', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
await expect(wiki.getWikiAsset('assets/ghost.png')).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
});
describe('wiki — GitHub fallback when no wiki is on disk', () => {
const ok = (body: string): Response =>
({ ok: true, status: 200, text: async () => body, arrayBuffer: async () => new ArrayBuffer(4) }) as unknown as Response;
it('fetches from GitHub and caches the result', async () => {
const wiki = await loadWiki(MISSING_WIKI);
expect(wiki.isLocalWiki()).toBe(false);
fetchSpy.mockResolvedValue(ok('## Docs\n- [[Home]]\n'));
const first = await wiki.getWikiIndex();
const second = await wiki.getWikiIndex();
expect(first.sections[0].pages).toEqual([{ title: 'Home', slug: 'Home' }]);
expect(second).toEqual(first);
expect(fetchSpy).toHaveBeenCalledTimes(1); // second read served from cache
expect(fetchSpy.mock.calls[0][0]).toContain('raw.githubusercontent.com');
});
it('serves a stale cache entry when GitHub is unreachable', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockResolvedValueOnce(ok('# Cached\n'));
const before = await wiki.getWikiPage('Home');
fetchSpy.mockRejectedValue(new Error('network down'));
const after = await wiki.getWikiPage('Home');
expect(after.title).toBe(before.title);
});
it('passes a deadline to every GitHub request', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockResolvedValue(ok('# Timed'));
await wiki.getWikiPage('Home');
expect(fetchSpy.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('falls through to WikiNotFound when GitHub declares a body over the cap', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
headers: { get: (h: string) => (h === 'content-length' ? String(5 * 1024 * 1024) : null) },
text: async () => '# Huge',
} as unknown as Response);
await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
it('404s when GitHub is unreachable and nothing is cached', async () => {
const wiki = await loadWiki(MISSING_WIKI);
fetchSpy.mockRejectedValue(new Error('network down'));
await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound);
});
});
describe('wiki — default path resolution', () => {
it("resolves the repo's wiki/ with no override, so a release never ships without docs", async () => {
// Guards the __dirname anchor and the Dockerfile COPY: if either breaks, help
// silently degrades to the GitHub fallback and nobody notices.
const wiki = await loadWiki(undefined);
expect(wiki.isLocalWiki()).toBe(true);
const page = await wiki.getWikiPage('Home');
expect(page.title.length).toBeGreaterThan(0);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('wiki — full-text search', () => {
const ok = (body: string): Response =>
({ ok: true, status: 200, text: async () => body, arrayBuffer: async () => new ArrayBuffer(4) }) as unknown as Response;
it('ranks a title match first and points at the heading the text sits under', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const hits = await wiki.searchWiki('cover image');
expect(hits[0]).toMatchObject({
slug: 'Sample',
title: 'Sample Page',
section: 'Planning',
heading: 'Cover images',
anchor: 'cover-images',
});
expect(hits[0].snippet).toContain('cover image');
// Only pages that carry the words come back; the fixture home page does not.
expect(hits.map((h) => h.slug)).not.toContain('Home');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('finds a page by a word in its body and hands back a snippet around it', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const hits = await wiki.searchWiki('restored');
expect(hits).toHaveLength(1);
expect(hits[0].slug).toBe('Sample');
expect(hits[0].heading).toBe('Archiving');
expect(hits[0].snippet).toMatch(/restored/);
expect(hits[0].snippet).not.toContain('```');
});
it('matches the page title without the words appearing in the body', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const hits = await wiki.searchWiki('fixture');
expect(hits[0].slug).toBe('Home');
expect(hits[0].anchor).toBeNull();
});
it('returns nothing for an empty or single-letter query', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
expect(await wiki.searchWiki(' ')).toEqual([]);
expect(await wiki.searchWiki('a')).toEqual([]);
expect(await wiki.searchWiki('zzzz-no-such-word')).toEqual([]);
});
it('honours the limit and clamps it to a sane range', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
// Both fixture pages mention "page"; ask for one.
const one = await wiki.searchWiki('page', 1);
expect(one).toHaveLength(1);
const all = await wiki.searchWiki('page', 500);
expect(all.length).toBeGreaterThan(1);
expect(all.length).toBeLessThanOrEqual(20);
});
it('builds the index once for the bundled wiki and reuses it', async () => {
const wiki = await loadWiki(FIXTURE_WIKI);
const first = await wiki.searchWiki('archive');
const second = await wiki.searchWiki('archive');
expect(second).toEqual(first);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('searches the GitHub fallback and skips a page that fails to load', async () => {
const wiki = await loadWiki(MISSING_WIKI);
expect(wiki.isLocalWiki()).toBe(false);
fetchSpy.mockImplementation(async (url: string) => {
if (url.endsWith('_Sidebar.md')) return ok('## Docs\n- [[Home]]\n- [[Broken]]\n');
if (url.endsWith('Home.md')) return ok('# Remote Home\n\nInvite links expire after a week.\n');
return { ok: false, status: 500, text: async () => '' } as unknown as Response;
});
const hits = await wiki.searchWiki('invite');
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ slug: 'Home', title: 'Remote Home' });
});
});