Files
readest/apps/readest-app/src/__tests__/services/environment.test.ts
T
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00

268 lines
12 KiB
TypeScript

import { describe, test, expect, beforeEach, vi } from 'vitest';
// ── Mocks for constants ──────────────────────────────────────────
vi.mock('@/services/constants', () => ({
READEST_WEB_BASE_URL: 'https://web.readest.com',
READEST_NODE_BASE_URL: 'https://node.readest.com',
}));
// We need to reset modules between tests to pick up env var changes,
// so we import dynamically in each test or test group.
// Cast process.env to a mutable record for test manipulation
const env = process.env as Record<string, string | undefined>;
const originalEnv = { ...env };
beforeEach(() => {
vi.resetModules();
Object.keys(env).forEach((key) => delete env[key]);
Object.assign(env, originalEnv);
// Clean up any window globals we set
delete (window as unknown as Record<string, unknown>)['__READEST_CLI_ACCESS'];
delete (window as unknown as Record<string, unknown>)['__READEST_RUNTIME_CONFIG'];
});
describe('environment', () => {
// ── isTauriAppPlatform ─────────────────────────────────────────
describe('isTauriAppPlatform', () => {
test('returns true when NEXT_PUBLIC_APP_PLATFORM is tauri', async () => {
env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri';
const { isTauriAppPlatform } = await import('@/services/environment');
expect(isTauriAppPlatform()).toBe(true);
});
test('returns false when NEXT_PUBLIC_APP_PLATFORM is web', async () => {
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
const { isTauriAppPlatform } = await import('@/services/environment');
expect(isTauriAppPlatform()).toBe(false);
});
test('returns false when NEXT_PUBLIC_APP_PLATFORM is not set', async () => {
delete env['NEXT_PUBLIC_APP_PLATFORM'];
const { isTauriAppPlatform } = await import('@/services/environment');
expect(isTauriAppPlatform()).toBe(false);
});
});
// ── isWebAppPlatform ───────────────────────────────────────────
describe('isWebAppPlatform', () => {
test('returns true when NEXT_PUBLIC_APP_PLATFORM is web', async () => {
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
const { isWebAppPlatform } = await import('@/services/environment');
expect(isWebAppPlatform()).toBe(true);
});
test('returns false when NEXT_PUBLIC_APP_PLATFORM is tauri', async () => {
env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri';
const { isWebAppPlatform } = await import('@/services/environment');
expect(isWebAppPlatform()).toBe(false);
});
test('returns false when NEXT_PUBLIC_APP_PLATFORM is not set', async () => {
delete env['NEXT_PUBLIC_APP_PLATFORM'];
const { isWebAppPlatform } = await import('@/services/environment');
expect(isWebAppPlatform()).toBe(false);
});
});
// ── hasCli ─────────────────────────────────────────────────────
describe('hasCli', () => {
test('returns true when __READEST_CLI_ACCESS is true', async () => {
window.__READEST_CLI_ACCESS = true;
const { hasCli } = await import('@/services/environment');
expect(hasCli()).toBe(true);
});
test('returns false when __READEST_CLI_ACCESS is not set', async () => {
const { hasCli } = await import('@/services/environment');
expect(hasCli()).toBe(false);
});
test('returns false when __READEST_CLI_ACCESS is explicitly false', async () => {
window.__READEST_CLI_ACCESS = false;
const { hasCli } = await import('@/services/environment');
expect(hasCli()).toBe(false);
});
});
// ── isPWA ──────────────────────────────────────────────────────
describe('isPWA', () => {
test('returns false by default (jsdom matchMedia mock returns false)', async () => {
const { isPWA } = await import('@/services/environment');
expect(isPWA()).toBe(false);
});
test('returns true when display-mode is standalone', async () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi
.fn()
.mockReturnValue({ matches: true }) as unknown as typeof window.matchMedia;
const { isPWA } = await import('@/services/environment');
expect(isPWA()).toBe(true);
window.matchMedia = originalMatchMedia;
});
});
// ── getBaseUrl ─────────────────────────────────────────────────
describe('getBaseUrl', () => {
test('returns runtime-configured apiBaseUrl when set', async () => {
window.__READEST_RUNTIME_CONFIG = {
apiBaseUrl: 'https://runtime-api.example.com',
};
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
});
test('returns API_BASE_URL when set', async () => {
env['API_BASE_URL'] = 'https://runtime-api.example.com';
delete env['NEXT_PUBLIC_API_BASE_URL'];
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://runtime-api.example.com');
});
test('returns NEXT_PUBLIC_API_BASE_URL when set', async () => {
env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com';
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://custom-api.example.com');
});
test('falls back to READEST_WEB_BASE_URL when env var not set', async () => {
delete env['NEXT_PUBLIC_API_BASE_URL'];
const { getBaseUrl } = await import('@/services/environment');
expect(getBaseUrl()).toBe('https://web.readest.com');
});
});
// ── getNodeBaseUrl ─────────────────────────────────────────────
describe('getNodeBaseUrl', () => {
test('returns NEXT_PUBLIC_NODE_BASE_URL when set', async () => {
env['NEXT_PUBLIC_NODE_BASE_URL'] = 'https://custom-node.example.com';
const { getNodeBaseUrl } = await import('@/services/environment');
expect(getNodeBaseUrl()).toBe('https://custom-node.example.com');
});
test('falls back to READEST_NODE_BASE_URL when env var not set', async () => {
delete env['NEXT_PUBLIC_NODE_BASE_URL'];
const { getNodeBaseUrl } = await import('@/services/environment');
expect(getNodeBaseUrl()).toBe('https://node.readest.com');
});
});
// ── isMacPlatform ──────────────────────────────────────────────
describe('isMacPlatform', () => {
test('returns true when navigator.platform contains Mac', async () => {
Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(true);
});
test('returns true when navigator.platform is iPhone', async () => {
Object.defineProperty(navigator, 'platform', { value: 'iPhone', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(true);
});
test('returns true when navigator.platform is iPad', async () => {
Object.defineProperty(navigator, 'platform', { value: 'iPad', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(true);
});
test('returns true when navigator.platform is iPod', async () => {
Object.defineProperty(navigator, 'platform', { value: 'iPod', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(true);
});
test('returns false when navigator.platform is Win32', async () => {
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(false);
});
test('returns false when navigator.platform is Linux', async () => {
Object.defineProperty(navigator, 'platform', { value: 'Linux x86_64', configurable: true });
const { isMacPlatform } = await import('@/services/environment');
expect(isMacPlatform()).toBe(false);
});
});
// ── getCommandPaletteShortcut ──────────────────────────────────
describe('getCommandPaletteShortcut', () => {
test('returns Mac shortcut on Mac platforms', async () => {
Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true });
const { getCommandPaletteShortcut } = await import('@/services/environment');
expect(getCommandPaletteShortcut()).toContain('P');
});
test('returns Ctrl shortcut on non-Mac platforms', async () => {
Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true });
const { getCommandPaletteShortcut } = await import('@/services/environment');
expect(getCommandPaletteShortcut()).toBe('Ctrl+Shift+P');
});
});
// ── getAPIBaseUrl ──────────────────────────────────────────────
describe('getAPIBaseUrl', () => {
test('returns /api in web development mode', async () => {
env['NODE_ENV'] = 'development';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
const { getAPIBaseUrl } = await import('@/services/environment');
expect(getAPIBaseUrl()).toBe('/api');
});
test('returns full URL in production', async () => {
env['NODE_ENV'] = 'production';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
delete env['NEXT_PUBLIC_API_BASE_URL'];
const { getAPIBaseUrl } = await import('@/services/environment');
expect(getAPIBaseUrl()).toBe('https://web.readest.com/api');
});
test('returns full URL for tauri platform even in development', async () => {
env['NODE_ENV'] = 'development';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri';
delete env['NEXT_PUBLIC_API_BASE_URL'];
const { getAPIBaseUrl } = await import('@/services/environment');
expect(getAPIBaseUrl()).toBe('https://web.readest.com/api');
});
});
// ── getNodeAPIBaseUrl ──────────────────────────────────────────
describe('getNodeAPIBaseUrl', () => {
test('returns /api in web development mode', async () => {
env['NODE_ENV'] = 'development';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
const { getNodeAPIBaseUrl } = await import('@/services/environment');
expect(getNodeAPIBaseUrl()).toBe('/api');
});
test('returns full node URL in production', async () => {
env['NODE_ENV'] = 'production';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'web';
delete env['NEXT_PUBLIC_NODE_BASE_URL'];
const { getNodeAPIBaseUrl } = await import('@/services/environment');
expect(getNodeAPIBaseUrl()).toBe('https://node.readest.com/api');
});
test('returns full node URL for tauri platform even in development', async () => {
env['NODE_ENV'] = 'development';
env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri';
delete env['NEXT_PUBLIC_NODE_BASE_URL'];
const { getNodeAPIBaseUrl } = await import('@/services/environment');
expect(getNodeAPIBaseUrl()).toBe('https://node.readest.com/api');
});
});
// ── environmentConfig default export ───────────────────────────
describe('environmentConfig', () => {
test('exports an object with getAppService function', async () => {
const envConfig = await import('@/services/environment');
expect(typeof envConfig.default.getAppService).toBe('function');
});
});
});