Remove initial server health check and bump Harbor versions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 12s

This commit is contained in:
2026-07-13 10:31:01 +03:00
parent 90447de0aa
commit 8c19f2cba9
17 changed files with 398 additions and 53 deletions

View File

@@ -279,10 +279,27 @@ docker compose -f docker-compose.gateway.yml logs --tail=100
```bash ```bash
docker compose -f docker-compose.gateway.yml config docker compose -f docker-compose.gateway.yml config
docker compose -f docker-compose.client.yml config docker compose -f docker-compose.client.yml config
docker compose -f docker-compose.client.local.yml config
``` ```
Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose. Эти команды только проверяют и показывают итоговую конфигурацию Docker Compose.
### Локальное тестирование Harbor Connect
Тестовый Connect запускается рядом с установленным клиентом и использует отдельные контейнер, volumes и порты:
```bash
docker compose -f docker-compose.client.local.yml up -d --build
```
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
```bash
docker compose -f docker-compose.client.local.yml down -v
```
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
## Служебные команды ## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется. Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.

View File

@@ -0,0 +1,24 @@
name: harbor-connect-local
services:
harbor-connect:
extends:
file: docker-compose.client.yml
service: harbor-connect
container_name: harbor-connect-local
environment:
PORT: ${LOCAL_CLIENT_UI_PORT:-3457}
PROXY_PORT: ${LOCAL_CLIENT_PROXY_PORT:-8083}
ports: !override
- "127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}:${LOCAL_CLIENT_UI_PORT:-3457}"
- "127.0.0.1:${LOCAL_CLIENT_PROXY_PORT:-8083}:${LOCAL_CLIENT_PROXY_PORT:-8083}"
volumes: !override
- vpn-proxy-client-local-data:/var/lib/vpn-proxy
- sing-box-client-local-cache:/var/lib/sing-box
- ./.runtime:/run/harbor-host:ro
healthcheck:
test: ["CMD", "curl", "--noproxy", "*", "-fsS", "http://127.0.0.1:${LOCAL_CLIENT_UI_PORT:-3457}/api/state"]
volumes:
vpn-proxy-client-local-data:
sing-box-client-local-cache:

View File

@@ -34,7 +34,7 @@ export function affectedComponents(files) {
else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) { else if (/^(src\/web\/|public\/|index\.html$|vite\.config\.js$)/.test(file)) {
add('macClient', 'gatewayClient'); add('macClient', 'gatewayClient');
} else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend'); } else if (/^src\/server\//.test(file)) add('macClient', 'gatewayBackend');
else if (/^(Dockerfile\.client|docker-compose\.client\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) { else if (/^(Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
add('macClient'); add('macClient');
} else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) { } else if (/^(Dockerfile|Dockerfile\.runtime-base|docker-compose\.gateway\.yml|entrypoint\.sh|scripts\/(deploy-gateway|build-runtime-base|build-on-107-deploy-111)\.sh)$/.test(file)) {
add('gatewayBackend'); add('gatewayBackend');

View File

@@ -43,6 +43,11 @@ import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000; const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000; const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
'SUBSCRIPTION_REJECTED',
]);
fs.mkdirSync(settings.dataDir, { recursive: true }); fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -215,6 +220,16 @@ function buildActiveConfig(
const stopSingbox = () => singboxRuntime.stop(); const stopSingbox = () => singboxRuntime.stop();
const startSingbox = () => singboxRuntime.apply(); const startSingbox = () => singboxRuntime.apply();
function resetSavedSubscription({ stopRuntime = true } = {}) {
return serializeControl(async () => {
if (stopRuntime) await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.remove();
updateStoredState((state) => ({ routeRules: state.routeRules }));
gatewayAutoState = createGatewayAutoState();
});
}
async function publicState() { async function publicState() {
const runtime = await singboxRuntime.refresh(); const runtime = await singboxRuntime.refresh();
const state = normalizeStoredState(stateStore.read()); const state = normalizeStoredState(stateStore.read());
@@ -529,10 +544,17 @@ function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise; if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => { subscriptionRefreshPromise = (async () => {
const subscriptionUrl = stateStore.read().subscriptionUrl; try {
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID'); const subscriptionUrl = stateStore.read().subscriptionUrl;
const parsed = await fetchSubscription(subscriptionUrl); if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
return commitSubscription(subscriptionUrl, parsed); const parsed = await fetchSubscription(subscriptionUrl);
return await commitSubscription(subscriptionUrl, parsed);
} catch (error) {
if (TERMINAL_SUBSCRIPTION_CODES.has(error?.code)) {
await resetSavedSubscription();
}
throw error;
}
})().finally(() => { })().finally(() => {
subscriptionRefreshPromise = null; subscriptionRefreshPromise = null;
}); });
@@ -654,13 +676,7 @@ async function handleApi(req, res) {
} }
if (req.method === 'DELETE' && req.url === '/api/subscription') { if (req.method === 'DELETE' && req.url === '/api/subscription') {
await withOperation('subscription-forget', () => serializeControl(async () => { await withOperation('subscription-forget', () => resetSavedSubscription());
await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.remove();
updateStoredState((state) => ({ routeRules: state.routeRules }));
gatewayAutoState = createGatewayAutoState();
}));
return sendState(res); return sendState(res);
} }
@@ -755,7 +771,13 @@ process.on('SIGINT', shutdown);
await refreshGatewayAutoMode({ reconfigure: false }) await refreshGatewayAutoMode({ reconfigure: false })
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`)); .catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
writeCurrentConfig(); try {
writeCurrentConfig();
} catch (error) {
if (!String(error?.code || '').startsWith('SUBSCRIPTION_')) throw error;
console.warn(`[storage] сохранённая подписка отклонена: ${error.message}; возврат к первичной настройке`);
await resetSavedSubscription({ stopRuntime: false });
}
} }
await startSingbox() await startSingbox()
.then(() => { .then(() => {

View File

@@ -10,6 +10,23 @@ import {
import { atomicWriteFile } from './services/stateStore.js'; import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const UNSPECIFIED_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
function usableProxyOutbound(outbound) {
const host = String(outbound?.server || '').trim().toLowerCase();
const port = Number(outbound?.server_port);
return Boolean(host) && !UNSPECIFIED_HOSTS.has(host) && Number.isInteger(port) && port > 0 && port <= 65535;
}
function rejectedSubscriptionCode(outbounds) {
const labels = outbounds.map((outbound) => String(outbound?.tag || '').toLowerCase()).join(' ');
if (labels.includes('expired')) return 'SUBSCRIPTION_EXPIRED';
if (labels.includes('disabled')) return 'SUBSCRIPTION_DISABLED';
if (/traffic|quota|bandwidth|трафик/.test(labels)) return 'SUBSCRIPTION_TRAFFIC_EXHAUSTED';
return outbounds.some((outbound) => UNSPECIFIED_HOSTS.has(String(outbound?.server || '').trim().toLowerCase()))
? 'SUBSCRIPTION_REJECTED'
: 'SUBSCRIPTION_INVALID';
}
export function getHwid() { export function getHwid() {
fs.mkdirSync(settings.dataDir, { recursive: true }); fs.mkdirSync(settings.dataDir, { recursive: true });
@@ -114,9 +131,18 @@ export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {}; const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : []; const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = []; const servers = [];
const rejectedOutbounds = [];
const seen = new Set(); const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => { const normalizedOutbounds = outbounds.flatMap((outbound) => {
if (!outbound || typeof outbound !== 'object') {
rejectedOutbounds.push(outbound);
return [];
}
if (!PROXY_TYPES.has(outbound.type)) return [outbound]; if (!PROXY_TYPES.has(outbound.type)) return [outbound];
if (!usableProxyOutbound(outbound)) {
rejectedOutbounds.push(outbound);
return [];
}
const id = createServerId(outbound); const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants. // ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return []; if (seen.has(id)) return [];
@@ -125,7 +151,7 @@ export function normalizeSubscriptionConfig(value) {
return [{ ...outbound, tag: id }]; return [{ ...outbound, tag: id }];
}); });
if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID'); if (!servers.length) throw new HarborError(rejectedSubscriptionCode(rejectedOutbounds));
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers }; return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
} }
@@ -198,6 +224,12 @@ export async function fetchSubscription(url, options) {
const body = await response.text(); const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
if (userInfo.expire > 0 && userInfo.expire * 1000 <= Date.now()) {
throw new HarborError('SUBSCRIPTION_EXPIRED');
}
if (userInfo.total > 0 && (userInfo.upload || 0) + (userInfo.download || 0) >= userInfo.total) {
throw new HarborError('SUBSCRIPTION_TRAFFIC_EXHAUSTED');
}
const parsed = parseSubscriptionBody(body); const parsed = parseSubscriptionBody(body);
return { return {

View File

@@ -3,6 +3,10 @@ export const ERROR_DEFINITIONS = Object.freeze({
REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false }, REQUEST_INVALID: { status: 400, message: 'Запрос содержит некорректные данные.', retryable: false },
ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false }, ENDPOINT_NOT_FOUND: { status: 404, message: 'Запрошенный API-метод не найден.', retryable: false },
SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false }, SUBSCRIPTION_INVALID: { status: 400, message: 'Ссылка подписки недействительна.', retryable: false },
SUBSCRIPTION_EXPIRED: { status: 400, message: 'Срок действия подписки истёк.', retryable: false },
SUBSCRIPTION_TRAFFIC_EXHAUSTED: { status: 400, message: 'Трафик по подписке закончился.', retryable: false },
SUBSCRIPTION_DISABLED: { status: 400, message: 'Подписка отключена провайдером.', retryable: false },
SUBSCRIPTION_REJECTED: { status: 400, message: 'Провайдер отклонил подписку.', retryable: false },
PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true }, PROVIDER_UNAVAILABLE: { status: 502, message: 'Провайдер подписки временно недоступен.', retryable: true },
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true }, STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false }, SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.7.23', macClient: '0.7.36',
gatewayClient: '0.7.23', gatewayClient: '0.7.34',
gatewayBackend: '0.7.1', gatewayBackend: '0.7.5',
}); });
export function parseVersion(value) { export function parseVersion(value) {

View File

@@ -50,6 +50,11 @@ export const api = {
state: () => request('/api/state'), state: () => request('/api/state'),
version: () => request('/api/version'), version: () => request('/api/version'),
subscription: { subscription: {
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', { fetch: (url) => request('/api/subscription/fetch', {
method: 'POST', method: 'POST',
body: JSON.stringify({ url }), body: JSON.stringify({ url }),

View File

@@ -17,6 +17,7 @@ import { instructionBlocks } from '../instructions.js';
import { operationBlocked } from '../state/operations.js'; import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx'; import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { ServerPicker } from './ServerPicker.jsx'; import { ServerPicker } from './ServerPicker.jsx';
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
import { canAppendRouteRule } from '../../shared/routingRules.js'; import { canAppendRouteRule } from '../../shared/routingRules.js';
import { import {
HARBOR_VERSIONS, HARBOR_VERSIONS,
@@ -134,10 +135,10 @@ function InlineError({ error, context }) {
return ( return (
<div className={`client-inline-error is-${context}`} role="alert"> <div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span> <span>{error.message}</span>
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
{error.correlationId && ( {error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small> <small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)} )}
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
</div> </div>
); );
} }
@@ -234,10 +235,11 @@ const localRuleValues = (rules) => rules
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules)); const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`; const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
function localRuleStatus(rule, savedRules, activeRules) { function localRuleStatus(rule, savedRules, activeRules, runtimeActive) {
const key = localRuleKey(rule); const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено']; if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено']; if (!rule.enabled) return ['disabled', 'Выключено'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно']; if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска']; return ['pending', 'Ждёт перезапуска'];
} }
@@ -340,6 +342,7 @@ function LocalRulesPanel({
rules, rules,
savedRules, savedRules,
activeRules, activeRules,
runtimeActive,
blocked, blocked,
dirty, dirty,
restartPending, restartPending,
@@ -399,7 +402,12 @@ function LocalRulesPanel({
<span id="local-rules-list-title">Правила</span> <span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list"> <div className="client-local-rules-list">
{rules.map((rule, index) => { {rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(rule, savedRules, activeRules); const [status, statusLabel] = localRuleStatus(
rule,
savedRules,
activeRules,
runtimeActive,
);
return ( return (
<div <div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`} className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
@@ -626,6 +634,12 @@ export function ClientOverviewPage({
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription); const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [accessTab, setAccessTab] = useState('gateway'); const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null); const [copyFeedback, setCopyFeedback] = useState(null);
const [subscriptionValidation, setSubscriptionValidation] = useState({
url: '',
status: 'idle',
error: null,
});
const [subscriptionValidationAttempt, setSubscriptionValidationAttempt] = useState(0);
const [refreshingInfo, setRefreshingInfo] = useState(false); const [refreshingInfo, setRefreshingInfo] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false); const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0); const [serverRevealVersion, setServerRevealVersion] = useState(0);
@@ -670,9 +684,21 @@ export function ClientOverviewPage({
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)] ? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
: instructionGuides; : instructionGuides;
const normalizedSubscriptionUrl = subscriptionUrl.trim(); const normalizedSubscriptionUrl = subscriptionUrl.trim();
const currentSubscriptionValidation = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation
: null;
const localSubscriptionError = normalizedSubscriptionUrl
&& !isSubscriptionUrlValid(normalizedSubscriptionUrl)
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
: null;
const subscriptionError = currentSubscriptionValidation?.error
|| localSubscriptionError
|| (error?.context === 'subscription' ? error : null);
const subscriptionValidationStatus = !normalizedSubscriptionUrl const subscriptionValidationStatus = !normalizedSubscriptionUrl
? 'idle' ? 'idle'
: isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid'; : subscriptionError || !isSubscriptionUrlValid(normalizedSubscriptionUrl)
? 'invalid'
: currentSubscriptionValidation?.status || 'checking';
const subscriptionWaiting = hasSubscription && !subscriptionContentReady; const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
const connectionBlocked = operationBlocked(operations, 'connection'); const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply'); const serverApplyBlocked = operationBlocked(operations, 'serverApply');
@@ -682,9 +708,12 @@ export function ClientOverviewPage({
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto'); const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const routeRulesBlocked = operationBlocked(operations, 'routeRules'); const routeRulesBlocked = operationBlocked(operations, 'routeRules');
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current; const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
const pendingLocalRulesCount = (state?.route?.localRules || []).filter((rule) => ( const localRulesPendingRestart = connected && state?.route?.localRulesPendingRestart === true;
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule)) const pendingLocalRulesCount = localRulesPendingRestart
)).length; ? (state?.route?.localRules || []).filter((rule) => (
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
useEffect(() => { useEffect(() => {
setNow(Date.now()); setNow(Date.now());
@@ -698,6 +727,36 @@ export function ClientOverviewPage({
if (editingSubscription) subscriptionInputRef.current?.focus(); if (editingSubscription) subscriptionInputRef.current?.focus();
}, [editingSubscription]); }, [editingSubscription]);
useEffect(() => {
if (!normalizedSubscriptionUrl || !isSubscriptionUrlValid(normalizedSubscriptionUrl)) return undefined;
const controller = new AbortController();
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking', error: null });
const timer = setTimeout(async () => {
try {
await api.subscription.validate(normalizedSubscriptionUrl, { signal: controller.signal });
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid', error: null });
} catch (requestError) {
if (requestError?.name === 'AbortError') return;
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'invalid',
error: {
context: 'subscription',
message: requestError.message,
correlationId: requestError.correlationId,
retry: requestError.retryable
? () => setSubscriptionValidationAttempt((attempt) => attempt + 1)
: null,
},
});
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedSubscriptionUrl, subscriptionValidationAttempt]);
useEffect(() => { useEffect(() => {
if (!showIntro) return undefined; if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200); const timer = setTimeout(() => setShowIntro(false), 1200);
@@ -956,7 +1015,7 @@ export function ClientOverviewPage({
localRulesBaselineRef.current = JSON.stringify(rules); localRulesBaselineRef.current = JSON.stringify(rules);
setLocalRulesRevision(result.state.route.localRulesRevision); setLocalRulesRevision(result.state.route.localRulesRevision);
setConfirmingLocalRulesClose(false); setConfirmingLocalRulesClose(false);
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false); if (!connected || !result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
} }
function requestCloseLocalRules() { function requestCloseLocalRules() {
@@ -1030,7 +1089,7 @@ export function ClientOverviewPage({
</button>} </button>}
{hasSubscription && subscriptionContentReady && <button {hasSubscription && subscriptionContentReady && <button
ref={localRulesToggleRef} ref={localRulesToggleRef}
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${state?.route?.localRulesPendingRestart ? ' has-pending' : ''}`} className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
type="button" type="button"
disabled={gatewayDirect} disabled={gatewayDirect}
aria-expanded={localRulesOpen} aria-expanded={localRulesOpen}
@@ -1047,7 +1106,7 @@ export function ClientOverviewPage({
</svg> </svg>
<span>{gatewayDirect <span>{gatewayDirect
? 'Сейчас работают правила Gateway' ? 'Сейчас работают правила Gateway'
: state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span> : localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>} </button>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}> <main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
{showPower && ( {showPower && (
@@ -1287,7 +1346,11 @@ export function ClientOverviewPage({
placeholder="Вставьте ссылку подписки" placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''} className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl} value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)} onChange={(event) => {
if (error?.context === 'subscription') onDismissError();
setSubscriptionValidation({ url: '', status: 'idle', error: null });
setSubscriptionUrl(event.target.value);
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) { if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl(''); setSubscriptionUrl('');
@@ -1304,18 +1367,21 @@ export function ClientOverviewPage({
<button <button
className="client-subscription-submit" className="client-subscription-submit"
type="submit" type="submit"
aria-live="polite"
aria-label={subscriptionValidationStatus === 'valid' aria-label={subscriptionValidationStatus === 'valid'
? 'Сохранить подписку' ? 'Сохранить подписку'
: 'Ссылка подписки не распознана'} : subscriptionValidationStatus === 'checking'
? 'Проверяем подписку'
: subscriptionError?.message || 'Ссылка подписки не распознана'}
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'} disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
> >
{subscriptionValidationStatus === 'valid' {subscriptionValidationStatus === 'valid'
? '✓' ? '✓'
: '×'} : subscriptionValidationStatus === 'checking' ? '…' : '×'}
</button> </button>
)} )}
</form> </form>
<InlineError error={error} context="subscription" /> <InlineError error={subscriptionError || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" /> <InlineProgress operations={operations} context="subscription" />
</div> </div>
@@ -1406,9 +1472,10 @@ export function ClientOverviewPage({
rules={localRulesDraft} rules={localRulesDraft}
savedRules={state?.route?.localRules || []} savedRules={state?.route?.localRules || []}
activeRules={state?.route?.activeLocalRules || []} activeRules={state?.route?.activeLocalRules || []}
runtimeActive={connected}
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)} blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
dirty={localRulesDirty} dirty={localRulesDirty}
restartPending={state?.route?.localRulesPendingRestart === true} restartPending={localRulesPendingRestart}
error={error} error={error}
operations={operations} operations={operations}
panelRef={localRulesPanelRef} panelRef={localRulesPanelRef}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { import {
autoServer, autoServer,
@@ -115,7 +115,6 @@ export function ServerPicker({
const [collapsed, setCollapsed] = useState([]); const [collapsed, setCollapsed] = useState([]);
const [pings, setPings] = useState({}); const [pings, setPings] = useState({});
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const initialCheckStarted = useRef(false);
const serverKey = servers.map(({ id }) => id).join('|'); const serverKey = servers.map(({ id }) => id).join('|');
useEffect(() => { useEffect(() => {
@@ -189,12 +188,6 @@ export function ServerPicker({
} }
} }
useEffect(() => {
if (initialCheckStarted.current || !servers.length) return;
initialCheckStarted.current = true;
checkVisible();
}, [serverKey]);
if (servers.length === 1) { if (servers.length === 1) {
return <section className="client-servers" aria-label="Выберите сервер"> return <section className="client-servers" aria-label="Выберите сервер">
{prompt && <span className="client-server-prompt">Выберите сервер</span>} {prompt && <span className="client-server-prompt">Выберите сервер</span>}

View File

@@ -1930,6 +1930,12 @@ p {
place-items: center; place-items: center;
} }
.client-duration-unit {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.client-duration-unit .client-duration-part.is-value { .client-duration-unit .client-duration-part.is-value {
display: inline-block; display: inline-block;
min-width: 2ch; min-width: 2ch;
@@ -1941,13 +1947,8 @@ p {
text-align: left; text-align: left;
} }
.client-duration-unit[data-unit='days'] .is-label { width: 4ch; } .client-duration-unit + .client-duration-unit {
.client-duration-unit[data-unit='hours'] .is-label { width: 5ch; } margin-left: 0.9em;
.client-duration-unit[data-unit='minutes'] .is-label { width: 6ch; }
.client-duration-unit[data-unit='seconds'] .is-label { width: 7ch; }
.client-duration-unit + .client-duration-unit::before {
content: ' ';
} }
.client-duration-seconds-value { .client-duration-seconds-value {
@@ -2637,7 +2638,7 @@ p {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
width: min(100%, 210px); width: min(100%, 150px);
margin-inline: auto; margin-inline: auto;
} }
@@ -3055,7 +3056,6 @@ p {
} }
.client-servers.is-scalable .client-server-grid { .client-servers.is-scalable .client-server-grid {
width: 100%;
gap: 5px; gap: 5px;
} }
@@ -3274,6 +3274,36 @@ p {
top: calc(100% + 2px); top: calc(100% + 2px);
} }
.client-inline-error.is-subscription > span,
.client-inline-error.is-subscription > button {
animation: client-subscription-error-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-inline-error.is-subscription small {
flex-basis: 100%;
font-size: 8px;
font-weight: 500;
letter-spacing: 0.04em;
opacity: 0.45;
animation: client-subscription-code-in 560ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-subscription-error-in {
from {
opacity: 0;
filter: blur(7px);
transform: translateY(-3px);
}
}
@keyframes client-subscription-code-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(-2px);
}
}
.client-inline-error small { .client-inline-error small {
color: var(--client-muted); color: var(--client-muted);
font-size: 9px; font-size: 9px;
@@ -3598,6 +3628,7 @@ p {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.client-inline-error.is-subscription > *,
.client-operation-progress::before, .client-operation-progress::before,
.client-delete-strike, .client-delete-strike,
.client-power, .client-power,

View File

@@ -90,6 +90,60 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
); );
}); });
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
const port = await freePort();
const subscriptionUrl = 'https://provider.example/disabled';
const rejectedServer = {
type: 'vless',
tag: '🚫 Subscription disabled',
server: '0.0.0.0',
server_port: 1,
};
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
subscriptionUrl,
selectedTag: rejectedServer.tag,
servers: [rejectedServer],
routeRules,
}));
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
url: subscriptionUrl,
config: { outbounds: [rejectedServer] },
}));
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
const child = spawn(process.execPath, ['src/server/index.js'], {
cwd: root,
env: {
...process.env,
APP_MODE: 'client',
DATA_DIR: dir,
PORT: String(port),
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += chunk; });
t.after(async () => {
child.kill('SIGTERM');
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const state = await waitForState(port, child, () => stderr);
assert.equal(state.subscription.status, 'missing');
assert.equal(state.hasSubscription, false);
assert.deepEqual(state.servers, []);
assert.ok(state.route.localRules.some((rule) => (
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
)));
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
assert.equal(child.exitCode, null);
});
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => { test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
const binDir = path.join(dir, 'bin'); const binDir = path.join(dir, 'bin');
@@ -121,6 +175,7 @@ setInterval(() => {}, 60_000);
let providerFetchCount = 0; let providerFetchCount = 0;
let delayedPath = ''; let delayedPath = '';
let invalidNextPath = ''; let invalidNextPath = '';
let trafficExhaustedNextPath = '';
let delayedRequestStarted = null; let delayedRequestStarted = null;
let releaseDelayedRequest = null; let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => { const subscriptionServer = http.createServer(async (req, res) => {
@@ -134,6 +189,42 @@ setInterval(() => {}, 60_000);
res.writeHead(200, { 'content-type': 'text/plain' }); res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription'); return res.end('not a subscription');
} }
if (req.url === '/traffic' || req.url === trafficExhaustedNextPath) {
trafficExhaustedNextPath = '';
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=60; download=40; total=100; expire=4102444800',
});
return res.end(JSON.stringify({
outbounds: [{
type: 'vless',
tag: 'Account unavailable',
server: '0.0.0.0',
server_port: 1,
}],
}));
}
if (req.url === '/expired') {
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100; expire=1',
});
return res.end(JSON.stringify(config));
}
if (req.url === '/disabled') {
res.writeHead(200, {
'content-type': 'application/json',
'subscription-userinfo': 'upload=0; download=0; total=100; expire=4102444800',
});
return res.end(JSON.stringify({
outbounds: [{
type: 'vless',
tag: '🚫 Subscription disabled',
server: '0.0.0.0',
server_port: 1,
}],
}));
}
if (req.url === invalidNextPath) { if (req.url === invalidNextPath) {
invalidNextPath = ''; invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' }); res.writeHead(200, { 'content-type': 'text/plain' });
@@ -240,6 +331,9 @@ setInterval(() => {}, 60_000);
for (const [pathname, expectedCode] of [ for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'], ['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'], ['/invalid', 'SUBSCRIPTION_INVALID'],
['/expired', 'SUBSCRIPTION_EXPIRED'],
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
['/disabled', 'SUBSCRIPTION_DISABLED'],
]) { ]) {
const failedImport = await rawRequest( const failedImport = await rawRequest(
port, port,
@@ -262,6 +356,16 @@ setInterval(() => {}, 60_000);
); );
} }
trafficExhaustedNextPath = '/subscription/test';
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(exhaustedRefresh.response.status, 400);
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
const stateAfterExhaustedRefresh = await request(port, '/api/state');
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
revision = stateAfterExhaustedRefresh.revision;
const missingServer = await rawRequest( const missingServer = await rawRequest(
port, port,
'/api/apply', '/api/apply',

View File

@@ -54,3 +54,25 @@ test('removed selected server requires an explicit new choice', () => {
assert.equal(selectRefreshedServer(before[1].id, before, after), ''); assert.equal(selectRefreshedServer(before[1].id, before, after), '');
assert.equal(selectRefreshedServer('', before, after), ''); assert.equal(selectRefreshedServer('', before, after), '');
}); });
test('provider placeholders never become selectable servers', () => {
const disabled = outbound('🚫 Subscription disabled', '0.0.0.0', 1);
const trafficExhausted = outbound('🚫 Traffic limit exceeded', '0.0.0.0', 1);
assert.throws(
() => parse([disabled]),
(error) => error.code === 'SUBSCRIPTION_DISABLED',
);
assert.throws(
() => parse([outbound('Account error', '::', 1)]),
(error) => error.code === 'SUBSCRIPTION_REJECTED',
);
assert.throws(
() => parse([trafficExhausted]),
(error) => error.code === 'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
);
const parsed = parse([disabled, outbound('Amsterdam', 'nl.example')]);
assert.deepEqual(parsed.servers.map((server) => server.label), ['Amsterdam']);
assert.equal(parsed.config.outbounds.length, 1);
});

View File

@@ -12,6 +12,7 @@ const versions = {
test('version paths map to the components actually shipped by this repository', () => { test('version paths map to the components actually shipped by this repository', () => {
assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']); assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']);
assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']); assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']);
assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']);
assert.deepEqual(affectedComponents(['package-lock.json']), [ assert.deepEqual(affectedComponents(['package-lock.json']), [
'macClient', 'macClient',
'gatewayClient', 'gatewayClient',

View File

@@ -5,6 +5,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..'); const root = path.resolve(import.meta.dirname, '../..');
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8'); const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
function rule(selector, source = styles) { function rule(selector, source = styles) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -33,12 +34,15 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
test('server rows scroll without moving the subscription column or showing a scrollbar', () => { test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
const scroll = rule('.client-server-scroll'); const scroll = rule('.client-server-scroll');
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll'); const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
const grid = rule('.client-server-grid');
assert.match(scroll, /overflow-y:\s*auto/); assert.match(scroll, /overflow-y:\s*auto/);
assert.match(scroll, /scrollbar-width:\s*none/); assert.match(scroll, /scrollbar-width:\s*none/);
assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/); assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
assert.match(simpleScroll, /max-height:\s*none/); assert.match(simpleScroll, /max-height:\s*none/);
assert.match(simpleScroll, /overflow:\s*visible/); assert.match(simpleScroll, /overflow:\s*visible/);
assert.match(grid, /width:\s*min\(100%, 150px\)/);
assert.doesNotMatch(rule('.client-servers.is-scalable .client-server-grid'), /width:/);
}); });
test('tablet and mobile regions use normal flow with viewport-safe widths', () => { test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
@@ -92,3 +96,21 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/); assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/); assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
}); });
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
assert.match(component, /api\.subscription\.validate\(normalizedSubscriptionUrl/);
assert.match(component, /status: 'checking'/);
assert.match(component, /status: 'valid'/);
assert.match(component, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
assert.match(component, /subscriptionValidationStatus === 'checking' \? '…' : '×'/);
assert.match(component, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
assert.match(styles, /@keyframes client-subscription-error-in[\s\S]*filter:\s*blur\(7px\)/);
assert.match(styles, /@keyframes client-subscription-code-in[\s\S]*transform:\s*translateY\(-2px\)/);
assert.match(
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '',
/\.client-inline-error\.is-subscription > \*/,
);
});

View File

@@ -17,8 +17,11 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
assert.match(component, /activeLocalRules/); assert.match(component, /activeLocalRules/);
assert.match(component, /localRulesRevision/); assert.match(component, /localRulesRevision/);
assert.match(component, /Не сохранено/); assert.match(component, /Не сохранено/);
assert.match(component, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
assert.match(component, /Ждёт перезапуска/); assert.match(component, /Ждёт перезапуска/);
assert.match(component, /Перезапустить VPN/); assert.match(component, /Перезапустить VPN/);
assert.match(component, /const localRulesPendingRestart = connected && state\?\.route\?\.localRulesPendingRestart === true/);
assert.match(component, /if \(!connected \|\| !result\.state\.route\.localRulesPendingRestart\) setLocalRulesOpen\(false\)/);
assert.match(component, /client-deletable-row/); assert.match(component, /client-deletable-row/);
assert.match(component, /className="client-delete-strike"[\s\S]*onAnimationEnd/); assert.match(component, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
assert.doesNotMatch(component, /client-rule-delete-cross/); assert.doesNotMatch(component, /client-rule-delete-cross/);

View File

@@ -36,11 +36,9 @@ test('server picker handles 1, 30 and 300 stable-ID servers with duplicate label
assert.equal(SERVER_RESULT_WINDOW, 60); assert.equal(SERVER_RESULT_WINDOW, 60);
}); });
test('server picker checks health once on load, keeps manual refresh and bounds the result window', () => { test('server picker checks health only on manual refresh and bounds the result window', () => {
assert.doesNotMatch(overview, /pingAll|servers\.ping/); assert.doesNotMatch(overview, /pingAll|servers\.ping/);
assert.match(picker, /const initialCheckStarted = useRef\(false\)/); assert.doesNotMatch(picker, /checkVisible\(\);/);
assert.match(picker, /if \(initialCheckStarted\.current \|\| !servers\.length\) return/);
assert.match(picker, /initialCheckStarted\.current = true;\s*checkVisible\(\)/);
assert.match(picker, /onClick={checkVisible}/); assert.match(picker, /onClick={checkVisible}/);
assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/); assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/);
assert.match(picker, /700 - \(performance\.now\(\) - startedAt\)/); assert.match(picker, /700 - \(performance\.now\(\) - startedAt\)/);