mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule. - A Repo is a Collection (kind='repo') plus a `repository` row. The new `git` downloader clones it, then updates it (weekly by default, or Never: the clone is kept as it is and only updated on request). - Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses. An existing clone can be imported where it is. repos.yaml is the source of truth. - A GitHub-like page shows each repo's README, files, and history, with a ZIP download. READMEs are searchable under the global search's Other tab. - git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are updated. - Tagging a Repo moves its clone as one directory. Repo changes wait for repos.yaml to be imported at startup. - The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the destination. A failed tag move restores the Collection's tag. A Collection's directory cannot leave the media directory. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
4.4 KiB
JavaScript
139 lines
4.4 KiB
JavaScript
import React from 'react';
|
|
import {act, render, screen} from '../test-utils';
|
|
import {useSearchSuggestions} from './Search';
|
|
import {TagsContext} from '../Tags';
|
|
import {tagsContextFixture} from '../test-fixtures';
|
|
import {
|
|
searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions,
|
|
} from '../api';
|
|
|
|
// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row
|
|
// may only appear once the suggestion request has come back empty, never before the user has
|
|
// typed, and never while the request is pending.
|
|
|
|
jest.mock('../api', () => ({
|
|
...jest.requireActual('../api'),
|
|
searchSuggestions: jest.fn(),
|
|
searchEstimateFiles: jest.fn(),
|
|
searchEstimateMap: jest.fn(),
|
|
searchEstimateOthers: jest.fn(),
|
|
searchEstimateZims: jest.fn(),
|
|
}));
|
|
|
|
const DEBOUNCE = 500;
|
|
const pending = () => new Promise(() => {
|
|
});
|
|
|
|
let hook;
|
|
|
|
function Probe() {
|
|
hook = useSearchSuggestions('', [], false);
|
|
return <pre data-testid='results'>{JSON.stringify(hook.suggestionsResults)}</pre>;
|
|
}
|
|
|
|
const results = () => JSON.parse(screen.getByTestId('results').textContent);
|
|
|
|
const renderProbe = () => render(<Probe/>, {
|
|
contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
searchEstimateFiles.mockReturnValue(pending());
|
|
searchEstimateMap.mockReturnValue(pending());
|
|
searchEstimateOthers.mockReturnValue(pending());
|
|
searchEstimateZims.mockReturnValue(pending());
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('no Channels group before anything has been searched', () => {
|
|
searchSuggestions.mockReturnValue(pending());
|
|
renderProbe();
|
|
expect(results().channels).toBeUndefined();
|
|
});
|
|
|
|
test('no Channels group while the suggestion request is pending', () => {
|
|
searchSuggestions.mockReturnValue(pending());
|
|
renderProbe();
|
|
act(() => {
|
|
hook.setSearchStr('foo');
|
|
});
|
|
expect(results().channels).toBeUndefined();
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(DEBOUNCE);
|
|
});
|
|
expect(searchSuggestions).toHaveBeenCalledWith('foo');
|
|
expect(results().channels).toBeUndefined();
|
|
expect(hook.loading).toBe(true);
|
|
});
|
|
|
|
test('a Channels "No results" row once the server said there are none', async () => {
|
|
let resolve;
|
|
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
|
renderProbe();
|
|
act(() => {
|
|
hook.setSearchStr('foo');
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(DEBOUNCE);
|
|
});
|
|
await act(async () => {
|
|
resolve({channels: [], domains: [], authors: [], subjects: []});
|
|
});
|
|
expect(results().channels.results).toEqual([{title: 'No results'}]);
|
|
});
|
|
|
|
test('Channels rows once the server found some', async () => {
|
|
let resolve;
|
|
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
|
renderProbe();
|
|
act(() => {
|
|
hook.setSearchStr('foo');
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(DEBOUNCE);
|
|
});
|
|
await act(async () => {
|
|
resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []});
|
|
});
|
|
expect(results().channels.results.map(i => i.title)).toEqual(['Food']);
|
|
});
|
|
|
|
test('Repos rows link to each repo, and the Other estimate gets the search', async () => {
|
|
let resolve;
|
|
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
|
renderProbe();
|
|
act(() => {
|
|
hook.setSearchStr('kiwix');
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(DEBOUNCE);
|
|
});
|
|
await act(async () => {
|
|
resolve({channels: [], domains: [], authors: [], subjects: [], repos: [{id: 4, name: 'kiwix-tools'}]});
|
|
});
|
|
expect(results().repos.results).toEqual([{type: 'repo', title: 'kiwix-tools', id: 4, location: '/repos/4'}]);
|
|
// Repos count toward the Other tab by their name and README, not only by tag.
|
|
expect(searchEstimateOthers).toHaveBeenCalledWith([], 'kiwix');
|
|
});
|
|
|
|
test('no Repos group when none match', async () => {
|
|
let resolve;
|
|
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
|
|
renderProbe();
|
|
act(() => {
|
|
hook.setSearchStr('foo');
|
|
});
|
|
act(() => {
|
|
jest.advanceTimersByTime(DEBOUNCE);
|
|
});
|
|
await act(async () => {
|
|
resolve({channels: [], domains: [], authors: [], subjects: [], repos: []});
|
|
});
|
|
expect(results().repos).toBeUndefined();
|
|
});
|