feat(app-update): add in-app update flow with native e2e coverage

This commit is contained in:
Tommaso Casaburi
2026-03-19 20:19:48 +08:00
parent bc870ae438
commit a2286aa68e
61 changed files with 1900 additions and 249 deletions
@@ -3,7 +3,6 @@ import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import packageJson from '../../../../../package.json';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
import { INTERFACE_LANGUAGE_STORAGE_KEY } from '../../../../lib/constants';
@@ -16,8 +15,8 @@ const testState = vi.hoisted(() => ({
alertMock: vi.fn(),
applyAppUpdateMock: vi.fn(),
changeLanguageMock: vi.fn(),
fetchMock: vi.fn(),
fitExpandedImagesToScreen: false,
refreshAvailableUpdateMock: vi.fn(),
setFitExpandedImagesToScreenMock: vi.fn(),
setUnmuteExpandedVideoSoundMock: vi.fn(),
unmuteExpandedVideoSound: false,
@@ -47,7 +46,6 @@ vi.mock('../../../style-selector/style-selector', () => ({
default: () => null,
}));
/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */
const BoardModeIndicator = () => {
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
return <span data-testid='board-mode'>{enableInfiniteScroll ? 'infinite' : 'pagination'}</span>;
@@ -58,26 +56,14 @@ const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const createFetchResponse = (body: unknown) => ({
json: vi.fn().mockResolvedValue(body),
});
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
};
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
});
};
const findButtonByText = (text: string) => Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
describe('InterfaceSettings', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
@@ -87,18 +73,20 @@ describe('InterfaceSettings', () => {
testState.alertMock.mockReset();
testState.applyAppUpdateMock.mockReset();
testState.changeLanguageMock.mockReset();
testState.fetchMock.mockReset();
testState.fitExpandedImagesToScreen = false;
testState.refreshAvailableUpdateMock.mockReset();
testState.setFitExpandedImagesToScreenMock.mockReset();
testState.setUnmuteExpandedVideoSoundMock.mockReset();
testState.unmuteExpandedVideoSound = false;
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
useAppUpdateStore.setState({
needRefresh: false,
availableUpdate: null,
isApplyingUpdate: false,
isCheckingForUpdate: false,
applyAppUpdate: testState.applyAppUpdateMock,
refreshAvailableUpdate: testState.refreshAvailableUpdateMock,
});
vi.stubGlobal('alert', testState.alertMock);
vi.stubGlobal('fetch', testState.fetchMock);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
@@ -119,7 +107,7 @@ describe('InterfaceSettings', () => {
it('renders enable_infinite_scroll checkbox unchecked by default', () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('enable_infinite_scroll'));
expect(label).toBeTruthy();
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
@@ -129,7 +117,7 @@ describe('InterfaceSettings', () => {
it('toggling checkbox updates persisted state and re-renders board mode', async () => {
render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator)));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
@@ -192,37 +180,70 @@ describe('InterfaceSettings', () => {
expect(localStorage.getItem(INTERFACE_LANGUAGE_STORAGE_KEY)).toBe('fr');
});
it('disables the update button while fetching and restores it afterward', async () => {
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
testState.fetchMock.mockReturnValueOnce(pendingFetch.promise);
it('renders a check button when no app update is available', () => {
render(createElement(InterfaceSettings));
expect(container.textContent).toContain('Update:');
expect(findButtonByText('Check')).toBeTruthy();
});
it('shows the checking status while an update check is in progress', () => {
useAppUpdateStore.setState({
isCheckingForUpdate: true,
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(findButtonByText('Check')?.disabled).toBe(true);
expect(container.textContent).toContain('checking_for_updates');
});
it('renders a download button and release link when an app update is available', () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
});
render(createElement(InterfaceSettings));
expect(findButtonByText('Download')).toBeTruthy();
const releaseLink = container.querySelector<HTMLAnchorElement>('a[href="https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9"]');
expect(releaseLink?.textContent).toBe('v9.9.9');
expect(container.textContent).toContain('new_version_found');
});
it('checks for app updates when the check button is pressed', async () => {
render(createElement(InterfaceSettings));
const button = findButtonByText('Check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(button?.disabled).toBe(true);
pendingFetch.resolve(createFetchResponse({ version: packageJson.version }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(button?.disabled).toBe(false);
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
expect(testState.refreshAvailableUpdateMock).toHaveBeenCalledTimes(1);
expect(testState.applyAppUpdateMock).not.toHaveBeenCalled();
});
it('applies the app update when a newer stable version is available', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' }));
it('applies the available app update when the button is pressed', async () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'android',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9.apk',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
const button = findButtonByText('Download');
expect(button).toBeTruthy();
await act(async () => {
@@ -234,12 +255,36 @@ describe('InterfaceSettings', () => {
expect(testState.alertMock).not.toHaveBeenCalled();
});
it('alerts when already on the latest stable version', async () => {
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: packageJson.version }));
it('disables the update button while an app update is already being applied', () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'web',
targetVersion: '9.9.9',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
isApplyingUpdate: true,
});
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(findButtonByText('Download')?.disabled).toBe(true);
});
it('alerts when applying the update fails', async () => {
testState.applyAppUpdateMock.mockRejectedValueOnce(new Error('installer failed'));
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'electron',
targetVersion: '9.9.9',
assetName: '5chan-9.9.9-x64.AppImage',
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.AppImage',
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
},
});
render(createElement(InterfaceSettings));
const button = findButtonByText('Download');
expect(button).toBeTruthy();
await act(async () => {
@@ -247,48 +292,6 @@ describe('InterfaceSettings', () => {
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
});
it('renders an update button when a service worker refresh is ready', () => {
useAppUpdateStore.setState({ needRefresh: true });
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
expect(button).toBeTruthy();
});
it('applies the waiting service worker when the update button is pressed', async () => {
useAppUpdateStore.setState({ needRefresh: true });
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.applyAppUpdateMock).toHaveBeenCalledTimes(1);
expect(testState.fetchMock).not.toHaveBeenCalled();
});
it('alerts when fetching the latest version info fails', async () => {
testState.fetchMock.mockRejectedValueOnce(new Error('network down'));
render(createElement(InterfaceSettings));
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check');
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(testState.alertMock).toHaveBeenCalledWith('Failed to fetch latest version info: Error: network down');
expect(testState.alertMock).toHaveBeenCalledWith('Error: installer failed');
});
});
@@ -51,6 +51,10 @@
padding-left: 19px;
}
.updateStatus {
margin-left: 8px;
}
.webUploadWarning {
font-size: 0.8em;
padding: 2px 0;
@@ -1,6 +1,5 @@
import { memo, useState } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import packageJson from '../../../../package.json';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
@@ -8,78 +7,50 @@ import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-sto
import Version from '../../version';
import StyleSelector from '../../style-selector/style-selector';
import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants';
import { fetchLatestStableVersion, isElectron } from '../../../lib/app-update';
import useAppUpdateStore from '../../../stores/use-app-update-store';
const commitRef = process.env.VITE_COMMIT_REF;
const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unknown>) => string, applyAppUpdate: () => Promise<void>): Promise<void> => {
try {
const latestStableVersion = await fetchLatestStableVersion();
let updateAvailable = false;
if (packageJson.version !== latestStableVersion) {
if (isElectron) {
const newVersionText = t('new_stable_version', { newVersion: latestStableVersion, oldVersion: packageJson.version });
const updateActionText = t('download_latest_desktop', { link: 'https://github.com/bitsocialnet/5chan/releases/latest', interpolation: { escapeValue: false } });
alert(newVersionText + ' ' + updateActionText);
} else {
await applyAppUpdate();
return;
}
updateAvailable = true;
}
if (commitRef && commitRef.length > 0) {
const commitRes = await fetch('https://api.github.com/repos/bitsocialnet/5chan/commits?per_page=1&sha=development', { cache: 'no-cache' });
const commitData = await commitRes.json();
const latestCommitHash = commitData[0].sha;
if (latestCommitHash.trim() !== commitRef.trim()) {
const newVersionText = t('new_development_version', { newCommit: latestCommitHash.slice(0, 7), oldCommit: commitRef.slice(0, 7) }) + ' ' + t('refresh_to_update');
alert(newVersionText);
updateAvailable = true;
}
}
if (!updateAvailable) {
alert(
commitRef
? `${t('latest_development_version', { commit: commitRef.slice(0, 7), link: `${window.location.origin}/#/`, interpolation: { escapeValue: false } })}`
: `${t('latest_stable_version', { version: packageJson.version })}`,
);
}
} catch (error) {
alert('Failed to fetch latest version info: ' + error);
}
};
const CheckForUpdates = () => {
const UpdateButton = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const needRefresh = useAppUpdateStore((state) => state.needRefresh);
const availableUpdate = useAppUpdateStore((state) => state.availableUpdate);
const isApplyingUpdate = useAppUpdateStore((state) => state.isApplyingUpdate);
const isCheckingForUpdate = useAppUpdateStore((state) => state.isCheckingForUpdate);
const applyAppUpdate = useAppUpdateStore((state) => state.applyAppUpdate);
const refreshAvailableUpdate = useAppUpdateStore((state) => state.refreshAvailableUpdate);
const checkForUpdates = async () => {
setLoading(true);
const handleUpdateAction = async () => {
try {
if (needRefresh) {
if (availableUpdate) {
await applyAppUpdate();
return;
}
await fetchLatestVersionInfo(t, applyAppUpdate);
} finally {
setLoading(false);
await refreshAvailableUpdate();
} catch (error) {
alert(String(error));
}
};
const buttonLabel = availableUpdate ? t('download') : t('check');
const isBusy = isApplyingUpdate || isCheckingForUpdate;
return (
<button className={styles.checkForUpdatesButton} onClick={checkForUpdates} disabled={loading}>
{needRefresh ? t('update') : t('check')}
</button>
<>
<button type='button' onClick={handleUpdateAction} disabled={isBusy}>
{capitalize(buttonLabel)}
</button>
{isCheckingForUpdate && (
<span className={styles.updateStatus} aria-live='polite'>
{t('checking_for_updates')}
</span>
)}
{!isCheckingForUpdate && availableUpdate && (
<span className={styles.updateStatus} aria-live='polite'>
{t('new_version_found')}:&nbsp;
<a href={availableUpdate.releaseUrl} target='_blank' rel='noopener noreferrer'>
v{availableUpdate.targetVersion}
</a>
</span>
)}
</>
);
};
@@ -117,7 +88,7 @@ const InterfaceSettings = () => {
{capitalize(t('version'))}: <Version />
</div>
<div className={styles.setting}>
{capitalize(t('update'))}: <CheckForUpdates />
{capitalize(t('update'))}: <UpdateButton />
</div>
<div className={styles.setting}>
{capitalize(t('interface_language'))}: <InterfaceLanguage />