app: search Other tab distinguishes pending channels from none (#728)
* app: search Other tab distinguishes pending channels from none
useSearchChannels initialised channels to [] and loading to false, and
only set loading inside its mount effect, so the Other tab's first
render painted "No Channels" with a count of 0 before the search had
run. A failed search was rendered the same way, since the hook had no
catch and the API helper returns undefined on a non-OK response.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] = no channels; loading
is true from the first render because the search starts on mount.
* Changing tags resets to pending, and only the newest request may
write state, so a slow response for previous tags is dropped.
* The tab shows a loader while pending, an error on failure, and
"No Channels" only once the server confirmed there are none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* app: useSearchChannels follows its caller's tags
The hook read its tag argument only as the initial state, so the
Other tab never refetched when the filter modal changed the URL's tags
while the tab stayed mounted; the list, including a stale "No
Channels", was whatever the mount fetched.
The internal tag state now follows the argument's contents, keyed on
the serialized list because the caller builds a new array every
render. Tests change the hook argument after a loaded result and
expect a second search and a reset to pending, check that a new array
with the same tags does not refetch, and keep the slow-response drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 12:01:37 -06:00
|
|
|
import React from 'react';
|
|
|
|
|
import {act, render, screen} from '../test-utils';
|
|
|
|
|
import {OtherSearchView} from './Search';
|
2026-10-07 17:06:01 -06:00
|
|
|
import {searchChannels, searchCollections, searchRepos} from '../api';
|
app: search Other tab distinguishes pending channels from none (#728)
* app: search Other tab distinguishes pending channels from none
useSearchChannels initialised channels to [] and loading to false, and
only set loading inside its mount effect, so the Other tab's first
render painted "No Channels" with a count of 0 before the search had
run. A failed search was rendered the same way, since the hook had no
catch and the API helper returns undefined on a non-OK response.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] = no channels; loading
is true from the first render because the search starts on mount.
* Changing tags resets to pending, and only the newest request may
write state, so a slow response for previous tags is dropped.
* The tab shows a loader while pending, an error on failure, and
"No Channels" only once the server confirmed there are none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* app: useSearchChannels follows its caller's tags
The hook read its tag argument only as the initial state, so the
Other tab never refetched when the filter modal changed the URL's tags
while the tab stayed mounted; the list, including a stale "No
Channels", was whatever the mount fetched.
The internal tag state now follows the argument's contents, keyed on
the serialized list because the caller builds a new array every
render. Tests change the hook argument after a loaded result and
expect a second search and a reset to pending, check that a new array
with the same tags does not refetch, and keep the slow-response drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 12:01:37 -06:00
|
|
|
|
|
|
|
|
// What the search page's Other tab shows for each state of useSearchChannels. "No Channels"
|
|
|
|
|
// must only appear once the server has confirmed there are none.
|
|
|
|
|
|
|
|
|
|
jest.mock('../api', () => ({
|
|
|
|
|
...jest.requireActual('../api'),
|
|
|
|
|
searchChannels: jest.fn(),
|
2026-10-07 17:06:01 -06:00
|
|
|
searchCollections: jest.fn(),
|
Add a Repos module that mirrors git repositories (#783)
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>
2026-10-07 15:16:40 -06:00
|
|
|
searchRepos: jest.fn(),
|
app: search Other tab distinguishes pending channels from none (#728)
* app: search Other tab distinguishes pending channels from none
useSearchChannels initialised channels to [] and loading to false, and
only set loading inside its mount effect, so the Other tab's first
render painted "No Channels" with a count of 0 before the search had
run. A failed search was rendered the same way, since the hook had no
catch and the API helper returns undefined on a non-OK response.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] = no channels; loading
is true from the first render because the search starts on mount.
* Changing tags resets to pending, and only the newest request may
write state, so a slow response for previous tags is dropped.
* The tab shows a loader while pending, an error on failure, and
"No Channels" only once the server confirmed there are none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* app: useSearchChannels follows its caller's tags
The hook read its tag argument only as the initial state, so the
Other tab never refetched when the filter modal changed the URL's tags
while the tab stayed mounted; the list, including a stale "No
Channels", was whatever the mount fetched.
The internal tag state now follows the argument's contents, keyed on
the serialized list because the caller builds a new array every
render. Tests change the hook argument after a loaded result and
expect a second search and a reset to pending, check that a new array
with the same tags does not refetch, and keep the slow-response drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 12:01:37 -06:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const NO_CHANNELS = /No Channels/;
|
|
|
|
|
|
Add a Repos module that mirrors git repositories (#783)
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>
2026-10-07 15:16:40 -06:00
|
|
|
beforeEach(() => {
|
|
|
|
|
searchChannels.mockReset();
|
|
|
|
|
searchRepos.mockReset();
|
|
|
|
|
searchRepos.mockResolvedValue({repos: [], total: 0});
|
2026-10-07 17:06:01 -06:00
|
|
|
searchCollections.mockReset();
|
|
|
|
|
searchCollections.mockResolvedValue({collections: []});
|
Add a Repos module that mirrors git repositories (#783)
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>
2026-10-07 15:16:40 -06:00
|
|
|
});
|
app: search Other tab distinguishes pending channels from none (#728)
* app: search Other tab distinguishes pending channels from none
useSearchChannels initialised channels to [] and loading to false, and
only set loading inside its mount effect, so the Other tab's first
render painted "No Channels" with a count of 0 before the search had
run. A failed search was rendered the same way, since the hook had no
catch and the API helper returns undefined on a non-OK response.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] = no channels; loading
is true from the first render because the search starts on mount.
* Changing tags resets to pending, and only the newest request may
write state, so a slow response for previous tags is dropped.
* The tab shows a loader while pending, an error on failure, and
"No Channels" only once the server confirmed there are none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* app: useSearchChannels follows its caller's tags
The hook read its tag argument only as the initial state, so the
Other tab never refetched when the filter modal changed the URL's tags
while the tab stayed mounted; the list, including a stale "No
Channels", was whatever the mount fetched.
The internal tag state now follows the argument's contents, keyed on
the serialized list because the caller builds a new array every
render. Tests change the hook argument after a loaded result and
expect a second search and a reset to pending, check that a new array
with the same tags does not refetch, and keep the slow-response drop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 12:01:37 -06:00
|
|
|
|
|
|
|
|
test('pending: shows a loader, not "No Channels"', () => {
|
|
|
|
|
searchChannels.mockReturnValue(new Promise(() => {
|
|
|
|
|
}));
|
|
|
|
|
const {container} = render(<OtherSearchView loading={false}/>);
|
|
|
|
|
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('failed: shows an error, not "No Channels"', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue(undefined);
|
|
|
|
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText(/Could not fetch the channels/)).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
|
|
|
|
} finally {
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty: shows "No Channels"', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('loaded: lists the channels', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
|
|
|
|
});
|
Add a Repos module that mirrors git repositories (#783)
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>
2026-10-07 15:16:40 -06:00
|
|
|
|
|
|
|
|
describe('Repos', () => {
|
|
|
|
|
test('pending: the Repos section loads while Channels are shown', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
searchRepos.mockReturnValue(new Promise(() => {
|
|
|
|
|
}));
|
|
|
|
|
const {container} = render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
|
|
|
|
|
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText('No Repos')).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty: shows "No Repos"', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText('No Repos')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('failed: shows an error only in the Repos section', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
|
|
|
|
searchRepos.mockRejectedValue(new Error('boom'));
|
|
|
|
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText(/Could not fetch the repos/)).toBeInTheDocument();
|
|
|
|
|
} finally {
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('loaded: lists repos by the search, with their README headline', async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
searchRepos.mockResolvedValue({
|
|
|
|
|
total: 1, repos: [{
|
|
|
|
|
id: 7, name: 'kiwix-tools', tag_name: null,
|
|
|
|
|
// The server escapes the README; only the highlight is markup.
|
|
|
|
|
readme_headline: 'Serve <b>Zim</b> files <script>',
|
|
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
const searchParams = new URLSearchParams('q=zim&tag=Software');
|
|
|
|
|
const {container} = render(<OtherSearchView loading={false}/>, {query: {searchParams}});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(searchRepos).toHaveBeenCalledWith('zim', ['Software']);
|
|
|
|
|
expect(screen.getByText('kiwix-tools').closest('a')).toHaveAttribute('href', '/repos/7');
|
|
|
|
|
expect(container.querySelector('u').textContent).toBe('Zim');
|
|
|
|
|
expect(container.querySelector('script')).toBeNull();
|
|
|
|
|
expect(screen.getByText(/files <script>/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-07 16:56:26 -06:00
|
|
|
|
2026-10-07 17:06:01 -06:00
|
|
|
// Domains and Playlists are Collections searched by kind.
|
|
|
|
|
describe.each([
|
|
|
|
|
['domain', 'Domains', 'example.com', '/archives?domain=example.com'],
|
|
|
|
|
['playlist', 'Playlists', 'Road Trip', '/playlists/3'],
|
|
|
|
|
])('%s Collections', (kind, title, name, href) => {
|
|
|
|
|
const collectionsOf = (wanted, collections) => (k) => Promise.resolve({collections: k === wanted ? collections : []});
|
|
|
|
|
|
|
|
|
|
test(`empty: shows "No ${title}"`, async () => {
|
|
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=nothing')}});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('nothing to search by: shows none without fetching', async () => {
|
2026-10-07 16:56:26 -06:00
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
|
|
|
|
render(<OtherSearchView loading={false}/>);
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
2026-10-07 17:06:01 -06:00
|
|
|
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
|
|
|
|
|
expect(searchCollections).not.toHaveBeenCalled();
|
2026-10-07 16:56:26 -06:00
|
|
|
});
|
|
|
|
|
|
2026-10-07 17:06:01 -06:00
|
|
|
test(`failed: shows an error only in the ${title} section`, async () => {
|
2026-10-07 16:56:26 -06:00
|
|
|
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
2026-10-07 17:06:01 -06:00
|
|
|
searchCollections.mockImplementation(k => k === kind ? Promise.reject(new Error('boom')) : Promise.resolve({collections: []}));
|
2026-10-07 16:56:26 -06:00
|
|
|
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
|
|
|
|
|
});
|
|
|
|
|
try {
|
2026-10-07 17:06:01 -06:00
|
|
|
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=x')}});
|
2026-10-07 16:56:26 -06:00
|
|
|
await act(async () => {
|
|
|
|
|
});
|
|
|
|
|
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
2026-10-07 17:06:01 -06:00
|
|
|
expect(screen.getByText(new RegExp(`Could not fetch the ${title.toLowerCase()}`))).toBeInTheDocument();
|
2026-10-07 16:56:26 -06:00
|
|
|
} finally {
|
|
|
|
|
spy.mockRestore();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-07 17:06:01 -06:00
|
|
|
test('loaded: lists them by the search and tags, linked', async () => {
|
2026-10-07 16:56:26 -06:00
|
|
|
searchChannels.mockResolvedValue({channels: []});
|
2026-10-07 17:06:01 -06:00
|
|
|
searchCollections.mockImplementation(collectionsOf(kind, [{id: 3, name, kind, tag_name: null}]));
|
|
|
|
|
const searchParams = new URLSearchParams('q=road&tag=News');
|
2026-10-07 16:56:26 -06:00
|
|
|
render(<OtherSearchView loading={false}/>, {query: {searchParams}});
|
|
|
|
|
await act(async () => {
|
|
|
|
|
});
|
2026-10-07 17:06:01 -06:00
|
|
|
expect(searchCollections).toHaveBeenCalledWith(kind, ['News'], 'road');
|
|
|
|
|
expect(screen.getByText(name).closest('a')).toHaveAttribute('href', href);
|
2026-10-07 16:56:26 -06:00
|
|
|
});
|
|
|
|
|
});
|