mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import path from 'path';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// 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('');
|
|
expect(page.markdown).toContain(''); // 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('');
|
|
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' });
|
|
});
|
|
});
|