Merge upstream/dev into feat/connectivity-travel
Keep both the Sports and Travel import lists.
No files matched your search
@@ -22,7 +22,7 @@
|
||||
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card v-for="(leak, index) in leakTest" :key="leak.id"
|
||||
<Card v-for="(leak, index) in leakTest" :key="leak.slot"
|
||||
class="keyboard-shortcut-card jn-card min-w-0 overflow-hidden transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50">
|
||||
<CardContent class="p-4 min-w-0">
|
||||
<!-- Top: heartbeat icon + name + index -->
|
||||
@@ -33,8 +33,7 @@
|
||||
|
||||
<span class="font-mono text-muted-foreground shrink-0">#{{ index + 1 }}</span>
|
||||
</div>
|
||||
<!-- Provider name (secondary information) — fixed per card, sourced
|
||||
from each provider's `name` export in utils/dnsleaks. -->
|
||||
<!-- Provider that actually answered (changes on fallback). -->
|
||||
<p class="w-full min-w-0 mb-1 text-xs font-mono text-muted-foreground truncate" :title="leak.providerName">
|
||||
{{ leak.providerName }}
|
||||
</p>
|
||||
@@ -108,13 +107,15 @@ import FitText from '@/components/widgets/FitText.vue';
|
||||
import InfoBanner from '@/components/widgets/InfoBanner.vue';
|
||||
import { INLINE_TIERS } from '@/composables/use-fit-text.js';
|
||||
import {
|
||||
ipApi, surfshark, ipleak, fastly, runWithRetry,
|
||||
ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak,
|
||||
buildFallbackChain, runWithFallback,
|
||||
} from '@/utils/dnsleaks';
|
||||
|
||||
// One source of truth for which providers to run and in what order. Adding
|
||||
// a new provider = create the file under utils/dnsleaks and append it here.
|
||||
// (browserleaks stays available in utils/dnsleaks but is benched for now.)
|
||||
const PROVIDERS = [ipApi, surfshark, ipleak, fastly];
|
||||
// All providers in priority order: the first SLOT_COUNT hold a card each,
|
||||
// the rest are standbys. New provider = file under utils/dnsleaks + append.
|
||||
const PROVIDERS = [ipApi, bashws, myipstack, fastly, browserleaks, surfshark, ipleak];
|
||||
const SLOT_COUNT = 4;
|
||||
const ACTIVE = PROVIDERS.slice(0, SLOT_COUNT);
|
||||
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -151,8 +152,11 @@ const createDefaultCard = () => ({
|
||||
org: t('dnsleaktest.StatusWait'),
|
||||
});
|
||||
|
||||
const leakTest = reactive(PROVIDERS.map((p) => ({
|
||||
// `slot` is the card's stable key; `id` / `providerName` follow the
|
||||
// provider that actually answered.
|
||||
const leakTest = reactive(ACTIVE.map((p) => ({
|
||||
...createDefaultCard(),
|
||||
slot: p.id,
|
||||
id: p.id,
|
||||
providerName: p.name,
|
||||
})));
|
||||
@@ -178,12 +182,14 @@ const markLeakCardError = (index) => {
|
||||
leakTest[index].org = t('dnsleaktest.StatusError');
|
||||
};
|
||||
|
||||
// Run one provider against its card slot, with retry (3 attempts, fresh
|
||||
// prefix per attempt — see `runWithRetry` in utils/dnsleaks). On full
|
||||
// failure (all attempts threw) the card is flipped to the error state.
|
||||
const runProvider = async (index, provider) => {
|
||||
// Run one slot through its fallback chain (see utils/dnsleaks); the card
|
||||
// flips to error only when the whole chain fails.
|
||||
const runProvider = async (index) => {
|
||||
const chain = buildFallbackChain(index, PROVIDERS, SLOT_COUNT);
|
||||
try {
|
||||
const { ip } = await runWithRetry(provider);
|
||||
const { ip, provider } = await runWithFallback(chain);
|
||||
leakTest[index].id = provider.id;
|
||||
leakTest[index].providerName = provider.name;
|
||||
leakTest[index].ip = ip;
|
||||
await applyMaxMindGeo(index, ip);
|
||||
} catch (error) {
|
||||
@@ -198,7 +204,9 @@ const checkAllDNSLeakTest = async (isRefresh) => {
|
||||
isStarted.value = true;
|
||||
if (isRefresh) {
|
||||
trackEvent('Section', 'RefreshClick', 'DNSLeakTest');
|
||||
leakTest.forEach((server) => {
|
||||
leakTest.forEach((server, index) => {
|
||||
server.id = ACTIVE[index].id;
|
||||
server.providerName = ACTIVE[index].name;
|
||||
server.ip = t('dnsleaktest.StatusWait');
|
||||
server.country = t('dnsleaktest.StatusWait');
|
||||
server.country_code = '';
|
||||
@@ -206,15 +214,13 @@ const checkAllDNSLeakTest = async (isRefresh) => {
|
||||
});
|
||||
}
|
||||
|
||||
const delayedRun = (provider, index, delay) => new Promise((resolve) => {
|
||||
const delayedRun = (index, delay) => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
runProvider(index, provider).then(resolve, resolve);
|
||||
runProvider(index).then(resolve, resolve);
|
||||
}, delay);
|
||||
});
|
||||
|
||||
const promises = PROVIDERS.map((provider, index) =>
|
||||
delayedRun(provider, index, index * 200),
|
||||
);
|
||||
const promises = ACTIVE.map((_, index) => delayedRun(index, index * 200));
|
||||
|
||||
const allSettledPromise = Promise.allSettled(promises);
|
||||
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
|
||||
@@ -88,8 +88,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<Progress :model-value="state.speedTest.progress" class="mb-6"
|
||||
:class="[progressIndicatorClass, state.speedTest.status === 'idle' ? 'invisible' : '']" />
|
||||
<div class="mb-6" :class="{ invisible: state.speedTest.status === 'idle' }">
|
||||
<p class="mb-2 text-sm text-muted-foreground" role="status" aria-live="polite" aria-atomic="true">
|
||||
{{ progressLabel }}
|
||||
</p>
|
||||
<Progress :model-value="state.speedTest.progress" :aria-label="progressLabel"
|
||||
:class="progressIndicatorClass" />
|
||||
</div>
|
||||
|
||||
<!-- 4 Metrics Tiles -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
@@ -140,14 +145,14 @@
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
{{ t('speedtest.score') }}
|
||||
{{ t('speedtest.videoStreaming') }} :
|
||||
{{ t('speedtest.videoStreaming') }} :
|
||||
<span :class="qualityBadgeClass(state.speedTest.streamingQuality)">{{ t('speedtest.quality.' +
|
||||
state.speedTest.streamingQuality) }}</span> ;
|
||||
{{ t('speedtest.gaming') }}:
|
||||
state.speedTest.streamingQuality) }}</span> ;
|
||||
{{ t('speedtest.gaming') }}:
|
||||
<span :class="qualityBadgeClass(state.speedTest.gamingQuality)">
|
||||
{{ t('speedtest.quality.' + state.speedTest.gamingQuality) }}
|
||||
</span> ;
|
||||
{{ t('speedtest.rtc') }}:
|
||||
</span> ;
|
||||
{{ t('speedtest.rtc') }}:
|
||||
<span :class="qualityBadgeClass(state.speedTest.rtcQuality)">
|
||||
{{ t('speedtest.quality.' + state.speedTest.rtcQuality) }}
|
||||
</span>
|
||||
@@ -172,7 +177,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, markRaw, onUnmounted } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, markRaw, onUnmounted, nextTick } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
@@ -183,6 +188,7 @@ import { isValidIP } from '@/utils/valid-ip.js';
|
||||
import { parseTrace } from '@/utils/parse-trace.js';
|
||||
import getCountryName from '@/data/country-name.js';
|
||||
import SpeedTestEngine from '@cloudflare/speedtest';
|
||||
import { createSpeedTestSession, getSpeedTestLiveValues } from '@/utils/speedtest-session.js';
|
||||
import useSpeedTestCharts from '@/composables/use-speedtest-charts.js';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -220,6 +226,7 @@ const state = reactive({
|
||||
rtcScore: '-',
|
||||
rtcQuality: '-',
|
||||
status: 'idle',
|
||||
phase: 'probe',
|
||||
hasScores: false,
|
||||
progress: 0,
|
||||
},
|
||||
@@ -228,7 +235,7 @@ const state = reactive({
|
||||
package: {
|
||||
download: { bytes: 50e6, count: 4 },
|
||||
upload: { bytes: 15e6, count: 4 },
|
||||
latency: { count: 30 },
|
||||
latency: { count: 20 },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -248,6 +255,13 @@ const isPaused = computed(() => state.speedTest.status === 'paused');
|
||||
const isFinished = computed(() => state.speedTest.status === 'finished');
|
||||
const isError = computed(() => state.speedTest.status === 'error');
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
if (isFinished.value) return t('speedtest.phaseFinished');
|
||||
if (isError.value) return t('speedtest.phaseFailed');
|
||||
const phase = t(`speedtest.phases.${state.speedTest.phase}`);
|
||||
return isPaused.value ? t('speedtest.phasePaused', { phase }) : phase;
|
||||
});
|
||||
|
||||
// CTA Icon: running=Pause, finished/error=Retry, otherwise=Start
|
||||
const ctaIcon = computed(() => {
|
||||
if (isRunning.value) return Pause;
|
||||
@@ -331,17 +345,11 @@ const connectionMethods = {
|
||||
// --- Test Engine ----------------------------------------------------------
|
||||
|
||||
let testEngine;
|
||||
let runId = 0;
|
||||
const engineMethods = {
|
||||
// Build a fresh engine. State reset is owned by speedTestController.
|
||||
reset() {
|
||||
return markRaw(new SpeedTestEngine({
|
||||
autoStart: false,
|
||||
measurements: [
|
||||
{ type: 'latency', numPackets: state.config.package.latency.count },
|
||||
{ type: 'download', bytes: state.config.package.download.bytes, count: state.config.package.download.count },
|
||||
{ type: 'upload', bytes: state.config.package.upload.bytes, count: state.config.package.upload.count },
|
||||
],
|
||||
}));
|
||||
return markRaw(createSpeedTestSession(SpeedTestEngine, state.config.package));
|
||||
},
|
||||
|
||||
updateResults(results) {
|
||||
@@ -363,33 +371,25 @@ const engineMethods = {
|
||||
// onUnmounted dropped the reference, so the engine may already be gone.
|
||||
updateProgress() {
|
||||
const rawData = testEngine?.results?.raw;
|
||||
if (!rawData) return;
|
||||
if (!rawData || testEngine.isProbe) return;
|
||||
// 3 stages: latency / download / upload.
|
||||
const perStage = 100 / 3;
|
||||
let progress = 0;
|
||||
if (rawData.latency?.started) progress += rawData.latency.finished ? perStage : perStage / 2;
|
||||
if (rawData.download?.started) progress += rawData.download.finished ? perStage : perStage / 2;
|
||||
if (rawData.upload?.started) progress += rawData.upload.finished ? perStage : perStage / 2;
|
||||
state.speedTest.progress = Math.min(progress, 100);
|
||||
state.speedTest.progress = Math.max(state.speedTest.progress, Math.min(progress, 100));
|
||||
},
|
||||
|
||||
// Live values via engine getters (supported API). Getters can briefly return
|
||||
// `null` between "started" and "first datapoint", so guard with `!= null`.
|
||||
// Keep preview readings until each formal metric has actual samples.
|
||||
// CF bandwidth/latency getters can return zero for an empty sample set.
|
||||
updateSpeedInRealTime() {
|
||||
if (state.speedTest.status === 'finished' || state.speedTest.status === 'error') return;
|
||||
try {
|
||||
const results = testEngine?.results;
|
||||
if (!results) return;
|
||||
|
||||
const dl = results.getDownloadBandwidth();
|
||||
const ul = results.getUploadBandwidth();
|
||||
const lat = results.getUnloadedLatency();
|
||||
const jit = results.getUnloadedJitter();
|
||||
|
||||
if (dl != null) state.speedTest.downloadSpeed = parseFloat((dl / 1e6).toFixed(2));
|
||||
if (ul != null) state.speedTest.uploadSpeed = parseFloat((ul / 1e6).toFixed(2));
|
||||
if (lat != null) state.speedTest.latency = parseFloat(lat.toFixed(2));
|
||||
if (jit != null) state.speedTest.jitter = parseFloat(jit.toFixed(2));
|
||||
Object.assign(state.speedTest, getSpeedTestLiveValues(results));
|
||||
|
||||
updateCharts(
|
||||
state.speedTest.downloadSpeed,
|
||||
@@ -406,17 +406,24 @@ const engineMethods = {
|
||||
|
||||
// --- Test Control ----------------------------------------------------------
|
||||
|
||||
const setupTestEngine = async () => {
|
||||
const loadConnection = async (currentRun) => {
|
||||
if (!state.connection.ip) {
|
||||
const connectionData = await connectionMethods.getIPFromSpeedTest();
|
||||
if (connectionData) {
|
||||
if (connectionData && currentRun === runId) {
|
||||
Object.assign(state.connection, connectionData);
|
||||
// Feed the Globalping picker + IP history.
|
||||
store.updateAllIPs([{ ip: connectionData.ip, country: connectionData.loc || '', location: connectionData.country || '' }]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
testEngine.onRunningChange = () => { state.speedTest.status = 'running'; };
|
||||
const setupTestEngine = () => {
|
||||
testEngine.onPhaseChange = (phase) => { state.speedTest.phase = phase; };
|
||||
testEngine.onRunningChange = (running) => {
|
||||
state.speedTest.status = running ? 'running' : 'paused';
|
||||
// Show that startup is underway before the first measurement arrives.
|
||||
if (running) state.speedTest.progress = Math.max(state.speedTest.progress, 5);
|
||||
};
|
||||
testEngine.onResultsChange = () => {
|
||||
engineMethods.updateProgress();
|
||||
engineMethods.updateSpeedInRealTime();
|
||||
@@ -514,23 +521,30 @@ const speedTestController = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRun = ++runId;
|
||||
testEngine?.destroy();
|
||||
resetChartData();
|
||||
destroyCharts();
|
||||
await initStartingPoints();
|
||||
|
||||
Object.assign(state.speedTest, {
|
||||
downloadSpeed: 0, uploadSpeed: 0, latency: 0, jitter: 0,
|
||||
downLoadedLatency: '-', upLoadedLatency: '-',
|
||||
streamingScore: '-', gamingScore: '-', rtcScore: '-',
|
||||
hasScores: false, progress: 0,
|
||||
hasScores: false, progress: 0, phase: 'probe',
|
||||
});
|
||||
|
||||
testEngine = engineMethods.reset();
|
||||
if (!testEngine) { console.error('Failed to initialize test engine'); return; }
|
||||
|
||||
await setupTestEngine();
|
||||
trackEvent('Section', 'StartClick', 'SpeedTest');
|
||||
setupTestEngine();
|
||||
testEngine.play();
|
||||
|
||||
// Resources enrich the running test; neither can delay the first request.
|
||||
void nextTick().then(() => {
|
||||
if (currentRun === runId) return initStartingPoints();
|
||||
}).catch(error => console.error('Error initializing speed test charts:', error));
|
||||
void loadConnection(currentRun).catch(error => console.error('Error loading speed test connection:', error));
|
||||
trackEvent('Section', 'StartClick', 'SpeedTest');
|
||||
} catch (error) {
|
||||
console.error('Error in speedTestController:', error);
|
||||
state.speedTest.status = 'error';
|
||||
@@ -549,11 +563,13 @@ onMounted(() => { store.setMountingStatus('SpeedTest', true); });
|
||||
// SpeedTestEngine would still try to write state refs that no longer exist.
|
||||
// No-ops, not null: the engine's queued timers call these unconditionally.
|
||||
onUnmounted(() => {
|
||||
runId += 1;
|
||||
if (testEngine) {
|
||||
testEngine.onRunningChange = () => {};
|
||||
testEngine.onResultsChange = () => {};
|
||||
testEngine.onFinish = () => {};
|
||||
testEngine.onError = () => {};
|
||||
testEngine.destroy();
|
||||
testEngine = null;
|
||||
}
|
||||
destroyCharts();
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent class="space-y-8 p-4 md:p-6">
|
||||
<p v-if="analysis.number" class="m-0 text-sm text-muted-foreground">
|
||||
{{ t('ipcalculator.InterpretedAs') }}
|
||||
<span class="font-mono font-medium text-foreground">{{ address.canonical }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Network math -->
|
||||
<CalcSection :icon="Network" :title="t('ipcalculator.section.Network')">
|
||||
<PrefixBitmap :value="value" :family="4" :prefix="cidr.prefix"
|
||||
@@ -47,7 +42,7 @@
|
||||
|
||||
<!-- Obfuscated notations -->
|
||||
<CalcSection :icon="EyeOff" :title="t('ipcalculator.section.Obfuscated')">
|
||||
<p class="m-0 text-xs text-muted-foreground">{{ t('ipcalculator.obfuscatedNote') }}</p>
|
||||
<p class="mb-2 text-xs text-muted-foreground">{{ t('ipcalculator.obfuscatedNote') }}</p>
|
||||
<dl class="m-0 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<ValueRow :label="t('ipcalculator.dottedHex')" :value="address.obfuscated.dottedHex" />
|
||||
<ValueRow :label="t('ipcalculator.dottedOctal')" :value="address.obfuscated.dottedOctal" />
|
||||
@@ -77,7 +72,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ArrowLeftRight, Binary, EyeOff, Network } from '@lucide/vue';
|
||||
import { ArrowLeftRight, Binary, EyeOff, Network, Info } from '@lucide/vue';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import CalcSection from './CalcSection.vue';
|
||||
import ValueRow from './ValueRow.vue';
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent class="space-y-8 p-4 md:p-6">
|
||||
<p v-if="analysis.number" class="m-0 text-sm text-muted-foreground">
|
||||
{{ t('ipcalculator.InterpretedAs') }}
|
||||
<span class="font-mono font-medium text-foreground">{{ address.compressed }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Canonical forms -->
|
||||
<CalcSection :icon="Globe" :title="t('ipcalculator.section.Address')">
|
||||
<dl class="m-0 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
@@ -89,7 +84,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Fingerprint, Globe, Network, Tag } from '@lucide/vue';
|
||||
import { Fingerprint, Globe, Network, Tag, Info } from '@lucide/vue';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import CalcSection from './CalcSection.vue';
|
||||
import ValueRow from './ValueRow.vue';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Buffer speed-test samples independently of the asynchronously loaded charts.
|
||||
import { ref, reactive } from 'vue';
|
||||
import { getSpeedTestSampleCounts } from '../utils/speedtest-session.js';
|
||||
|
||||
// Extract Chart.js related configurations
|
||||
const getChartConfig = (t) => ({
|
||||
@@ -74,7 +76,11 @@ const getChartConfig = (t) => ({
|
||||
}
|
||||
});
|
||||
|
||||
export default function useSpeedTestCharts(t) {
|
||||
const useSpeedTestCharts = (t, {
|
||||
loadChart = () => import('chart.js/auto'),
|
||||
scheduleFrame = (callback) => requestAnimationFrame(callback),
|
||||
now = () => Date.now(),
|
||||
} = {}) => {
|
||||
// Chart references
|
||||
const downloadChart = ref(null);
|
||||
const uploadChart = ref(null);
|
||||
@@ -92,6 +98,8 @@ export default function useSpeedTestCharts(t) {
|
||||
// Chart.js import): rapid double-starts must share one init instead of
|
||||
// racing two `new Chart()` calls onto the same canvas.
|
||||
let chartInitInFlight = null;
|
||||
let generation = 0;
|
||||
let sampleCounts = new WeakMap();
|
||||
|
||||
const chartData = reactive({
|
||||
download: {
|
||||
@@ -177,13 +185,28 @@ export default function useSpeedTestCharts(t) {
|
||||
}
|
||||
});
|
||||
|
||||
// Rendering consumes the buffer; it never owns or resets measurement data.
|
||||
const renderCharts = () => {
|
||||
Object.entries(charts).forEach(([type, chart]) => {
|
||||
if (!chart) return;
|
||||
const { labels, data } = chartData[type];
|
||||
chart.data.labels = [...labels];
|
||||
chart.data.datasets[0].data = type === 'download' || type === 'upload'
|
||||
? [...data]
|
||||
: data.map((y, index) => ({ x: Number(labels[index]), y }));
|
||||
chart.options.scales.x.max = labels.length ? Number(labels.at(-1)) : 0;
|
||||
chart.update('none');
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize charts
|
||||
const initCharts = async () => {
|
||||
// Dynamically import Chart.js. On broken/blocked visitor networks
|
||||
// the chunk can fail or resolve null — bail and run chartless
|
||||
// (downstream already null-guards every charts.* access).
|
||||
const mod = await import('chart.js/auto').catch(() => null);
|
||||
if (!mod?.Chart) return;
|
||||
const currentGeneration = generation;
|
||||
const mod = await loadChart().catch(() => null);
|
||||
if (currentGeneration !== generation || !mod?.Chart) return;
|
||||
const { Chart, registerables } = mod;
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -240,109 +263,65 @@ export default function useSpeedTestCharts(t) {
|
||||
options: getLineChartOptions(t('speedtest.Jitter') + ' (ms)')
|
||||
});
|
||||
}
|
||||
renderCharts();
|
||||
};
|
||||
|
||||
// Update charts
|
||||
// Raw result identity separates preview samples from formal-test samples.
|
||||
// Completion events can contain new samples even when `finished` is true.
|
||||
const updateCharts = (downloadSpeed, uploadSpeed, latency, jitter, rawData) => {
|
||||
if (!charts.download || !charts.upload || !charts.latency || !charts.jitter) return;
|
||||
|
||||
try {
|
||||
const currentTime = Date.now();
|
||||
|
||||
const updateSingleChart = (type, value, started) => {
|
||||
if (type === 'download' || type === 'upload') {
|
||||
if (value > 0 && started && !rawData[type]?.finished) {
|
||||
if (!chartData[type].started) {
|
||||
chartData[type].started = true;
|
||||
chartData[type].startTime = currentTime;
|
||||
}
|
||||
|
||||
const relativeTime = (currentTime - chartData[type].startTime) / 1000;
|
||||
const newLabel = relativeTime.toFixed(1);
|
||||
|
||||
chartData[type].labels.push(newLabel);
|
||||
chartData[type].data.push(value);
|
||||
|
||||
charts[type].data.labels = [...chartData[type].labels];
|
||||
charts[type].data.datasets[0].data = [...chartData[type].data];
|
||||
|
||||
const maxTime = Math.max(...chartData[type].labels.map(Number));
|
||||
charts[type].options.scales.x.max = maxTime;
|
||||
}
|
||||
} else if (value >= 0 && started) {
|
||||
if (!chartData[type].started) {
|
||||
chartData[type].started = true;
|
||||
chartData[type].startTime = currentTime;
|
||||
}
|
||||
|
||||
if ((type === 'latency' && !rawData.latency?.finished) ||
|
||||
(type === 'jitter' && !rawData.latency?.finished)) {
|
||||
|
||||
const relativeTime = (currentTime - chartData[type].startTime) / 1000;
|
||||
const newLabel = relativeTime.toFixed(1);
|
||||
const newData = parseFloat(value.toFixed(2));
|
||||
|
||||
chartData[type].labels.push(newLabel);
|
||||
chartData[type].data.push(newData);
|
||||
|
||||
const scatterData = chartData[type].data.map((value, index) => ({
|
||||
x: parseFloat(chartData[type].labels[index]),
|
||||
y: value
|
||||
}));
|
||||
charts[type].data.datasets[0].data = scatterData;
|
||||
|
||||
const maxTime = Math.max(...chartData[type].labels.map(Number));
|
||||
charts[type].options.scales.x.max = maxTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateSingleChart('latency', latency, rawData.latency?.started);
|
||||
updateSingleChart('jitter', jitter, rawData.latency?.started);
|
||||
updateSingleChart('download', downloadSpeed, rawData.download?.started);
|
||||
updateSingleChart('upload', uploadSpeed, rawData.upload?.started);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
Object.values(charts).forEach(chart => {
|
||||
if (chart) {
|
||||
chart.update('none');
|
||||
}
|
||||
});
|
||||
const counts = getSpeedTestSampleCounts(rawData);
|
||||
const seen = sampleCounts.get(rawData) || {};
|
||||
sampleCounts.set(rawData, seen);
|
||||
const values = { download: downloadSpeed, upload: uploadSpeed, latency, jitter };
|
||||
const currentTime = now();
|
||||
let changed = false;
|
||||
Object.entries(values).forEach(([type, value]) => {
|
||||
const count = counts[type === 'jitter' ? 'latency' : type];
|
||||
if (count < (type === 'jitter' ? 2 : 1) || count <= (seen[type] || 0)) return;
|
||||
if (!Number.isFinite(value) || value < 0) return;
|
||||
seen[type] = count;
|
||||
const series = chartData[type];
|
||||
if (!series.started) {
|
||||
series.started = true;
|
||||
series.startTime = currentTime;
|
||||
}
|
||||
series.labels.push(((currentTime - series.startTime) / 1000).toFixed(1));
|
||||
series.data.push(value);
|
||||
changed = true;
|
||||
});
|
||||
if (changed) {
|
||||
const currentGeneration = generation;
|
||||
scheduleFrame(() => {
|
||||
if (currentGeneration === generation) renderCharts();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating charts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize starting points of charts
|
||||
const initStartingPoints = async () => {
|
||||
// Ensure charts are initialized (shared across concurrent callers)
|
||||
const currentTime = now();
|
||||
['download', 'upload'].forEach(type => {
|
||||
const series = chartData[type];
|
||||
if (series.started) return;
|
||||
series.started = true;
|
||||
series.startTime = currentTime;
|
||||
series.labels = ['0.0'];
|
||||
series.data = [0];
|
||||
});
|
||||
if (!charts.download || !charts.upload) {
|
||||
chartInitInFlight ||= initCharts().finally(() => {
|
||||
chartInitInFlight = null;
|
||||
});
|
||||
if (!chartInitInFlight) {
|
||||
const pending = initCharts().finally(() => {
|
||||
if (chartInitInFlight === pending) chartInitInFlight = null;
|
||||
});
|
||||
chartInitInFlight = pending;
|
||||
}
|
||||
await chartInitInFlight;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
['download', 'upload'].forEach(type => {
|
||||
if (charts[type]) { // Add additional check
|
||||
chartData[type].started = true;
|
||||
chartData[type].startTime = currentTime;
|
||||
chartData[type].labels = ['0.0'];
|
||||
chartData[type].data = [0];
|
||||
|
||||
charts[type].data.labels = ['0.0'];
|
||||
charts[type].data.datasets[0].data = [0];
|
||||
charts[type].options.scales.x.max = 0;
|
||||
charts[type].update('none');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Clean up charts
|
||||
const destroyCharts = () => {
|
||||
generation += 1;
|
||||
chartInitInFlight = null;
|
||||
Object.values(charts).forEach(chart => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
@@ -353,6 +332,7 @@ export default function useSpeedTestCharts(t) {
|
||||
|
||||
// Reset chart data
|
||||
const resetChartData = () => {
|
||||
sampleCounts = new WeakMap();
|
||||
['download', 'upload', 'latency', 'jitter'].forEach(type => {
|
||||
chartData[type] = {
|
||||
started: false,
|
||||
@@ -376,4 +356,6 @@ export default function useSpeedTestCharts(t) {
|
||||
destroyCharts,
|
||||
resetChartData
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default useSpeedTestCharts;
|
||||
@@ -2101,6 +2101,39 @@
|
||||
"ru": "Новый инструмент: IP-калькулятор — вставьте любой IP, префикс, диапазон, список или число и получите расчёт подсети, преобразование записей, тип адреса и сведения об интерфейсе IPv6, всё вычисляется локально",
|
||||
"pt-BR": "Nova ferramenta: Calculadora de IP — cole qualquer IP, prefixo, intervalo, lista ou número e obtenha o cálculo de sub-rede, conversões de notação, tipo de endereço e detalhes de interface IPv6, tudo calculado localmente"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "improve",
|
||||
"change": {
|
||||
"en": "Improved the DNS Leak Test experience",
|
||||
"zh": "优化了 DNS 泄露测试的体验",
|
||||
"zh-TW": "優化了 DNS 洩漏測試的體驗",
|
||||
"fr": "Amélioration de l'expérience du test de fuite DNS",
|
||||
"ru": "Улучшен тест на утечку DNS",
|
||||
"pt-BR": "Melhorada a experiência do Teste de Vazamento de DNS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "improve",
|
||||
"change": {
|
||||
"en": "Improved the speed test experience on slow connections",
|
||||
"zh": "提升慢网用户测速体验",
|
||||
"zh-TW": "提升慢速網路使用者的測速體驗",
|
||||
"fr": "Amélioration de l’expérience du test de vitesse sur les connexions lentes",
|
||||
"ru": "Улучшен процесс тестирования скорости при медленном соединении",
|
||||
"pt-BR": "Melhorada a experiência do teste de velocidade em conexões lentas"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "fix",
|
||||
"change": {
|
||||
"en": "Fixed some issues",
|
||||
"zh": "修复了一些问题",
|
||||
"zh-TW": "修正了一些問題",
|
||||
"fr": "Correction de divers problèmes",
|
||||
"ru": "Исправлены некоторые проблемы",
|
||||
"pt-BR": "Corrigidos alguns problemas"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,6 +159,22 @@ export const IMPORT_LISTS = [
|
||||
{ id: 'reclameaqui', name: 'Reclame Aqui', url: 'https://www.reclameaqui.com.br/favicon.ico' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nigeria',
|
||||
emoji: '🇳🇬',
|
||||
members: [
|
||||
{ id: 'jumia', name: 'Jumia', url: 'https://www.jumia.com.ng/favicon.ico' },
|
||||
{ id: 'konga', name: 'Konga', url: 'https://www.konga.com/favicon.ico' },
|
||||
{ id: 'nairaland', name: 'Nairaland', url: 'https://www.nairaland.com/favicon.ico' },
|
||||
{ id: 'paystack', name: 'Paystack', url: 'https://paystack.com/favicon.ico' },
|
||||
{ id: 'flutterwave', name: 'Flutterwave', url: 'https://flutterwave.com/favicon.ico' },
|
||||
{ id: 'zenithbank', name: 'Zenith Bank', url: 'https://www.zenithbank.com/favicon.ico' },
|
||||
{ id: 'indrive', name: 'inDrive', url: 'https://indrive.com/favicon.ico' },
|
||||
{ id: 'chowdeck', name: 'Chowdeck', url: 'https://chowdeck.com/favicon.ico' },
|
||||
{ id: 'mtn', name: 'MTN', url: 'https://www.mtn.ng/favicon.ico' },
|
||||
{ id: 'boomplay', name: 'Boomplay', url: 'https://boomplay.com/favicon.ico' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'germany',
|
||||
emoji: '🇩🇪',
|
||||
@@ -195,6 +211,7 @@ export const IMPORT_LISTS = [
|
||||
{ id: 'kimi', name: 'Kimi', url: 'https://www.kimi.com/favicon.ico' },
|
||||
{ id: 'poe', name: 'Poe', url: 'https://poe.com/favicon.ico' },
|
||||
{ id: 'characterai', name: 'Character.ai', url: 'https://character.ai/favicon.ico' },
|
||||
{ id: 'perplexity', name: 'Perplexity', url: 'https://www.perplexity.ai/favicon.ico' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -287,6 +304,24 @@ export const IMPORT_LISTS = [
|
||||
{ id: 'iheart', name: 'iHeartRadio', url: 'https://www.iheart.com/favicon.ico' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sports',
|
||||
emoji: '🏆',
|
||||
members: [
|
||||
{ id: 'espn', name: 'ESPN', url: 'https://www.espn.com/favicon.ico' },
|
||||
{ id: 'nba', name: 'NBA', url: 'https://www.nba.com/favicon.ico' },
|
||||
{ id: 'fifa', name: 'FIFA', url: 'https://www.fifa.com/favicon.ico' },
|
||||
{ id: 'premierleague', name: 'Premier League', url: 'https://www.premierleague.com/favicon.ico' },
|
||||
{ id: 'formula1', name: 'Formula 1', url: 'https://www.formula1.com/favicon.ico' },
|
||||
{ id: 'dazn', name: 'DAZN', url: 'https://www.dazn.com/favicon.ico' },
|
||||
{ id: 'flashscore', name: 'Flashscore', url: 'https://www.flashscore.com/favicon.ico' },
|
||||
{ id: 'sofascore', name: 'Sofascore', url: 'https://www.sofascore.com/favicon.ico' },
|
||||
{ id: 'skysports', name: 'Sky Sports', url: 'https://www.skysports.com/favicon.ico' },
|
||||
{ id: 'bein-sports', name: 'beIN Sports', url: 'https://www.beinsports.com/favicon.ico' },
|
||||
{ id: 'mlb', name: 'MLB', url: 'https://www.mlb.com/favicon.ico' },
|
||||
{ id: 'nfl', name: 'NFL', url: 'https://www.nfl.com/favicon.ico' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'travel',
|
||||
emoji: '✈️',
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "Obfuscated",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "My IPs",
|
||||
"InterpretedAs": "Interpreted as",
|
||||
"copy": "Copy",
|
||||
"copyAll": "Copy all",
|
||||
"yes": "Yes",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "India Essentials",
|
||||
"china": "China Essentials",
|
||||
"brazil": "Brazil Essentials",
|
||||
"nigeria": "Nigeria Essentials",
|
||||
"germany": "Germany Essentials",
|
||||
"ai": "AI Services",
|
||||
"social": "Social & Messaging",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "Online Education",
|
||||
"streaming": "Streaming",
|
||||
"music": "Music & Audio",
|
||||
"sports": "Sports",
|
||||
"travel": "Travel & Booking",
|
||||
"gaming": "Gaming",
|
||||
"developer": "Developer",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": " to test server:",
|
||||
"connectionEnd": " .",
|
||||
"testFailed": "Could not reach the speed test server. Please check your connection and try again.",
|
||||
"phases": {
|
||||
"probe": "Preliminary test",
|
||||
"latency": "Latency test",
|
||||
"download": "Download test",
|
||||
"upload": "Upload test"
|
||||
},
|
||||
"phasePaused": "{phase} — paused",
|
||||
"phaseFinished": "Test complete",
|
||||
"phaseFailed": "Test failed",
|
||||
"quality": {
|
||||
"bad": "Bad",
|
||||
"poor": "Poor",
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "Obscurcie",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "Mes IP",
|
||||
"InterpretedAs": "Interprété comme",
|
||||
"copy": "Copier",
|
||||
"copyAll": "Tout copier",
|
||||
"yes": "Oui",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "Essentiels Inde",
|
||||
"china": "Essentiels Chine",
|
||||
"brazil": "Essentiels Brésil",
|
||||
"nigeria": "Essentiels Nigéria",
|
||||
"germany": "Essentiels Allemagne",
|
||||
"ai": "Services IA",
|
||||
"social": "Réseaux sociaux",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "Formation en ligne",
|
||||
"streaming": "Streaming",
|
||||
"music": "Musique et audio",
|
||||
"sports": "Sports",
|
||||
"travel": "Voyages et réservations",
|
||||
"gaming": "Jeux vidéo",
|
||||
"developer": "Développeurs",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": " vers le serveur de test:",
|
||||
"connectionEnd": " .",
|
||||
"testFailed": "Impossible de joindre le serveur de test de vitesse. Vérifiez votre connexion et réessayez.",
|
||||
"phases": {
|
||||
"probe": "Test préliminaire",
|
||||
"latency": "Test de latence",
|
||||
"download": "Test de téléchargement",
|
||||
"upload": "Test d’envoi"
|
||||
},
|
||||
"phasePaused": "{phase} — en pause",
|
||||
"phaseFinished": "Test terminé",
|
||||
"phaseFailed": "Échec du test",
|
||||
"quality": {
|
||||
"bad": "Mauvais",
|
||||
"poor": "Faible",
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "Ofuscado",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "Meus IPs",
|
||||
"InterpretedAs": "Interpretado como",
|
||||
"copy": "Copiar",
|
||||
"copyAll": "Copiar tudo",
|
||||
"yes": "Sim",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "Essenciais da Índia",
|
||||
"china": "Essenciais da China",
|
||||
"brazil": "Essenciais do Brasil",
|
||||
"nigeria": "Essenciais da Nigéria",
|
||||
"germany": "Essenciais da Alemanha",
|
||||
"ai": "Serviços de IA",
|
||||
"social": "Redes sociais e mensagens",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "Educação online",
|
||||
"streaming": "Streaming",
|
||||
"music": "Música e áudio",
|
||||
"sports": "Esportes",
|
||||
"travel": "Viagens e reservas",
|
||||
"gaming": "Jogos",
|
||||
"developer": "Desenvolvimento",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": " para o servidor de teste:",
|
||||
"connectionEnd": " .",
|
||||
"testFailed": "Não foi possível acessar o servidor de teste de velocidade. Verifique sua conexão e tente novamente.",
|
||||
"phases": {
|
||||
"probe": "Teste preliminar",
|
||||
"latency": "Teste de latência",
|
||||
"download": "Teste de download",
|
||||
"upload": "Teste de upload"
|
||||
},
|
||||
"phasePaused": "{phase} — pausado",
|
||||
"phaseFinished": "Teste concluído",
|
||||
"phaseFailed": "Falha no teste",
|
||||
"quality": {
|
||||
"bad": "Ruim",
|
||||
"poor": "Fraca",
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "Обфусцированный",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "Мои IP",
|
||||
"InterpretedAs": "Интерпретировано как",
|
||||
"copy": "Копировать",
|
||||
"copyAll": "Копировать всё",
|
||||
"yes": "Да",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "Индия: основные",
|
||||
"china": "Китай: основные",
|
||||
"brazil": "Бразилия: основные",
|
||||
"nigeria": "Нигерия: основные",
|
||||
"germany": "Германия: основные",
|
||||
"ai": "ИИ-сервисы",
|
||||
"social": "Соцсети",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "Онлайн-образование",
|
||||
"streaming": "Стриминг",
|
||||
"music": "Музыка и аудио",
|
||||
"sports": "Спорт",
|
||||
"travel": "Путешествия и бронирование",
|
||||
"gaming": "Игровые платформы",
|
||||
"developer": "Для разработчиков",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": ", тестовый сервер:",
|
||||
"connectionEnd": ".",
|
||||
"testFailed": "Не удалось подключиться к серверу тестирования скорости. Проверьте соединение и повторите попытку.",
|
||||
"phases": {
|
||||
"probe": "Предварительная проверка",
|
||||
"latency": "Проверка задержки",
|
||||
"download": "Проверка загрузки",
|
||||
"upload": "Проверка отдачи"
|
||||
},
|
||||
"phasePaused": "{phase} — приостановлено",
|
||||
"phaseFinished": "Тест завершён",
|
||||
"phaseFailed": "Ошибка теста",
|
||||
"quality": {
|
||||
"bad": "Очень плохо",
|
||||
"poor": "Плохо",
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "混淆寫法",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "我的 IP",
|
||||
"InterpretedAs": "解讀為",
|
||||
"copy": "複製",
|
||||
"copyAll": "全部複製",
|
||||
"yes": "是",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "印度常用",
|
||||
"china": "中國常用",
|
||||
"brazil": "巴西常用",
|
||||
"nigeria": "奈及利亞常用",
|
||||
"germany": "德國常用",
|
||||
"ai": "AI 服務",
|
||||
"social": "社交通訊",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "線上教育",
|
||||
"streaming": "流媒體",
|
||||
"music": "音樂與音訊",
|
||||
"sports": "體育",
|
||||
"travel": "旅行與預訂",
|
||||
"gaming": "遊戲平臺",
|
||||
"developer": "開發者",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": "與測速伺服器:",
|
||||
"connectionEnd": " 的測試。",
|
||||
"testFailed": "無法連線測速伺服器,請檢查網路後重試。",
|
||||
"phases": {
|
||||
"probe": "預探測",
|
||||
"latency": "延遲測試",
|
||||
"download": "下載測試",
|
||||
"upload": "上傳測試"
|
||||
},
|
||||
"phasePaused": "{phase}(已暫停)",
|
||||
"phaseFinished": "測試完成",
|
||||
"phaseFailed": "測試失敗",
|
||||
"quality": {
|
||||
"bad": "差",
|
||||
"poor": "較差",
|
||||
|
||||
@@ -912,7 +912,6 @@
|
||||
"exampleObfuscated": "混淆写法",
|
||||
"exampleEui64": "EUI-64",
|
||||
"MyIPs": "我的 IP",
|
||||
"InterpretedAs": "解读为",
|
||||
"copy": "复制",
|
||||
"copyAll": "全部复制",
|
||||
"yes": "是",
|
||||
@@ -1414,6 +1413,7 @@
|
||||
"india": "印度常用",
|
||||
"china": "中国常用",
|
||||
"brazil": "巴西常用",
|
||||
"nigeria": "尼日利亚常用",
|
||||
"germany": "德国常用",
|
||||
"ai": "AI 服务",
|
||||
"social": "社交通讯",
|
||||
@@ -1421,6 +1421,7 @@
|
||||
"education": "在线教育",
|
||||
"streaming": "流媒体",
|
||||
"music": "音乐与音频",
|
||||
"sports": "体育",
|
||||
"travel": "旅行与预订",
|
||||
"gaming": "游戏平台",
|
||||
"developer": "开发者",
|
||||
@@ -1472,6 +1473,15 @@
|
||||
"connectionTo": "与测速服务器:",
|
||||
"connectionEnd": " 的测试。",
|
||||
"testFailed": "无法连接测速服务器,请检查网络后重试。",
|
||||
"phases": {
|
||||
"probe": "预探测",
|
||||
"latency": "延迟测试",
|
||||
"download": "下载测试",
|
||||
"upload": "上传测试"
|
||||
},
|
||||
"phasePaused": "{phase}(已暂停)",
|
||||
"phaseFinished": "测试完成",
|
||||
"phaseFailed": "测试失败",
|
||||
"quality": {
|
||||
"bad": "差",
|
||||
"poor": "较差",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// bash.ws — a client-chosen session id becomes a wildcard zone: loading any
|
||||
// `*.<id>.bash.ws` URL forces a DNS lookup (the fetch itself fails on TLS,
|
||||
// which is fine), then `/dnsleak/test/<id>?json` lists what its resolver
|
||||
// saw: `[{ ip, type: 'ip' | 'dns' | 'conclusion', ... }]`.
|
||||
import { fetchWithTimeout } from '../fetch-with-timeout.js';
|
||||
|
||||
const ID_LENGTH = 20;
|
||||
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
const generateId = () => {
|
||||
const bytes = new Uint8Array(ID_LENGTH);
|
||||
crypto.getRandomValues(bytes);
|
||||
let id = '';
|
||||
for (let i = 0; i < ID_LENGTH; i++) {
|
||||
id += ALPHABET[bytes[i] % ALPHABET.length];
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
// First entry the upstream tagged as a resolver (`type: 'dns'`).
|
||||
export const pickDnsIp = (entries) => {
|
||||
if (!Array.isArray(entries)) return null;
|
||||
const hit = entries.find((entry) => entry?.type === 'dns' && typeof entry.ip === 'string');
|
||||
return hit ? hit.ip : null;
|
||||
};
|
||||
|
||||
export const bashws = {
|
||||
id: 'bashws',
|
||||
name: 'bash.ws',
|
||||
async run() {
|
||||
const id = generateId();
|
||||
// Trigger only — the certificate does not cover this depth, so the
|
||||
// request errors after the lookup has already reached upstream.
|
||||
await fetchWithTimeout(`https://ex.1.${id}.bash.ws/css/z.css`, {
|
||||
mode: 'no-cors',
|
||||
timeoutMs: 2500,
|
||||
}).catch(() => {});
|
||||
const response = await fetchWithTimeout(`https://bash.ws/dnsleak/test/${id}?json`);
|
||||
if (!response.ok) throw new Error('bashws: response not ok');
|
||||
const data = await response.json();
|
||||
const ip = pickDnsIp(data);
|
||||
if (!ip) throw new Error('bashws: no resolver IP in response');
|
||||
return { ip };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// Barrel + retry runner for the homepage DNS leak test providers.
|
||||
// Barrel + retry / fallback runners for the homepage DNS leak test providers.
|
||||
//
|
||||
// Each provider in this directory is a self-describing object:
|
||||
// { id, name, run() }
|
||||
@@ -14,6 +14,8 @@ export { surfshark } from './surfshark.js';
|
||||
export { ipleak } from './ipleak.js';
|
||||
export { browserleaks } from './browserleaks.js';
|
||||
export { fastly } from './fastly.js';
|
||||
export { bashws } from './bashws.js';
|
||||
export { myipstack } from './myipstack.js';
|
||||
|
||||
// Invoke `provider.run()` up to `attempts` times. Because each provider
|
||||
// regenerates its prefix internally on every call, retries trigger fresh
|
||||
@@ -21,7 +23,7 @@ export { fastly } from './fastly.js';
|
||||
// whose `ip` fails `isValidIP` counts as a failed attempt too — garbage
|
||||
// must never reach the MaxMind lookup downstream. Returns the first valid
|
||||
// result; throws the last error if every attempt failed.
|
||||
export async function runWithRetry(provider, attempts = 3) {
|
||||
export async function runWithRetry(provider, attempts = 2) {
|
||||
let lastError;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
@@ -36,3 +38,30 @@ export async function runWithRetry(provider, attempts = 3) {
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Try order for card slot `index`: own provider → standbys (index ≥
|
||||
// slotCount) → the other slots' providers from 0. Each call derives a fresh
|
||||
// hostname, so a neighbour's provider still yields an independent lookup.
|
||||
export const buildFallbackChain = (index, providers, slotCount) => {
|
||||
const primary = providers[index];
|
||||
if (!primary) return [];
|
||||
const standbys = providers.slice(slotCount);
|
||||
const neighbours = providers.slice(0, slotCount).filter((p) => p !== primary);
|
||||
return [primary, ...standbys, ...neighbours];
|
||||
};
|
||||
|
||||
// Walk the chain once, each provider through `runWithRetry`; resolves
|
||||
// `{ ip, provider }` so the caller can name the upstream that answered.
|
||||
// Throws the last error.
|
||||
export const runWithFallback = async (chain) => {
|
||||
let lastError = new Error('dnsleak: empty provider chain');
|
||||
for (const provider of chain) {
|
||||
try {
|
||||
const { ip } = await runWithRetry(provider);
|
||||
return { ip, provider };
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// dns.myipstack.com (hide.me) — a fresh label on `.dns.myipstack.com`
|
||||
// forces a unique DNS lookup; `/getRemoteAddress?name=<label>` echoes the
|
||||
// resolver as plain text `proto:ip:port`, e.g. `udp:1.2.3.4:41321`.
|
||||
import { fetchWithTimeout } from '../fetch-with-timeout.js';
|
||||
|
||||
const generatePrefix = () => {
|
||||
const unixTime = Date.now().toString();
|
||||
const fixedString = 'jason5ng32';
|
||||
const randomString = Math.random().toString(36).substring(2, 11);
|
||||
return unixTime + fixedString + randomString;
|
||||
};
|
||||
|
||||
// `proto:ip:port` → ip; the IP may itself contain colons (IPv6), so split on
|
||||
// the first and last colon only.
|
||||
export const parseRemoteAddress = (text) => {
|
||||
if (typeof text !== 'string') return null;
|
||||
const trimmed = text.trim();
|
||||
const first = trimmed.indexOf(':');
|
||||
const last = trimmed.lastIndexOf(':');
|
||||
if (first < 0 || last <= first) return null;
|
||||
const ip = trimmed.slice(first + 1, last);
|
||||
return ip || null;
|
||||
};
|
||||
|
||||
export const myipstack = {
|
||||
id: 'myipstack',
|
||||
name: 'dns.myipstack.com',
|
||||
async run() {
|
||||
const label = generatePrefix();
|
||||
const response = await fetchWithTimeout(
|
||||
`https://${label}.dns.myipstack.com/getRemoteAddress?name=${label}`,
|
||||
);
|
||||
if (!response.ok) throw new Error('myipstack: response not ok');
|
||||
const ip = parseRemoteAddress(await response.text());
|
||||
if (!ip) throw new Error('myipstack: no IP in response');
|
||||
return { ip };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
// Run a fixed preview before an independent, user-configured Cloudflare test.
|
||||
|
||||
export const getSpeedTestSampleCounts = (raw) => ({
|
||||
latency: raw.latency?.results?.timings?.length || 0,
|
||||
download: Object.values(raw.download?.results || {}).reduce((sum, bucket) => sum + bucket.timings.length, 0),
|
||||
upload: Object.values(raw.upload?.results || {}).reduce((sum, bucket) => sum + bucket.timings.length, 0),
|
||||
});
|
||||
|
||||
// Missing formal samples leave the corresponding preview reading untouched.
|
||||
export const getSpeedTestLiveValues = (results) => {
|
||||
const counts = getSpeedTestSampleCounts(results.raw);
|
||||
const metrics = [
|
||||
['downloadSpeed', 'getDownloadBandwidth', counts.download, 1e6],
|
||||
['uploadSpeed', 'getUploadBandwidth', counts.upload, 1e6],
|
||||
['latency', 'getUnloadedLatency', counts.latency, 1],
|
||||
['jitter', 'getUnloadedJitter', counts.latency >= 2, 1],
|
||||
];
|
||||
return Object.fromEntries(metrics.flatMap(([key, getter, hasSamples, divisor]) => {
|
||||
if (!hasSamples) return [];
|
||||
const value = results[getter]();
|
||||
return Number.isFinite(value) ? [[key, Number((value / divisor).toFixed(2))]] : [];
|
||||
}));
|
||||
};
|
||||
|
||||
export const createSpeedTestSession = (Engine, packages) => {
|
||||
// Snapshot options so the formal test uses the selection made at startup.
|
||||
const measurements = [
|
||||
{ type: 'latency', numPackets: packages.latency.count },
|
||||
{ type: 'download', bytes: packages.download.bytes, count: packages.download.count },
|
||||
{ type: 'upload', bytes: packages.upload.bytes, count: packages.upload.count },
|
||||
];
|
||||
let engine;
|
||||
let isProbe = true;
|
||||
let running = false;
|
||||
let disposed = false;
|
||||
let failed = false;
|
||||
|
||||
const detach = () => {
|
||||
engine.onRunningChange = () => {};
|
||||
engine.onPhaseChange = () => {};
|
||||
engine.onResultsChange = () => {};
|
||||
engine.onFinish = () => {};
|
||||
engine.onError = () => {};
|
||||
};
|
||||
|
||||
const session = {
|
||||
get results() { return engine.results; },
|
||||
get isProbe() { return isProbe; },
|
||||
onRunningChange: () => {},
|
||||
onPhaseChange: () => {},
|
||||
onResultsChange: () => {},
|
||||
onFinish: () => {},
|
||||
onError: () => {},
|
||||
play() {
|
||||
if (disposed || running) return;
|
||||
running = true;
|
||||
session.onRunningChange(true);
|
||||
engine.play();
|
||||
},
|
||||
pause() {
|
||||
if (disposed) return;
|
||||
running = false;
|
||||
engine.pause();
|
||||
session.onRunningChange(false);
|
||||
},
|
||||
destroy() {
|
||||
disposed = true;
|
||||
running = false;
|
||||
detach();
|
||||
engine.pause();
|
||||
},
|
||||
};
|
||||
|
||||
const prepare = () => {
|
||||
const current = new Engine({
|
||||
autoStart: false,
|
||||
...(isProbe ? {
|
||||
measurements: [
|
||||
{ type: 'latency', numPackets: 2 },
|
||||
{ type: 'download', bytes: 1e5, count: 1, bypassMinDuration: true },
|
||||
],
|
||||
// Even a fast preview must produce a reading; only the formal run is logged.
|
||||
bandwidthMinRequestDuration: 0,
|
||||
measureDownloadLoadedLatency: false,
|
||||
measureUploadLoadedLatency: false,
|
||||
logAimApiUrl: null,
|
||||
} : { measurements }),
|
||||
});
|
||||
engine = current;
|
||||
const isCurrent = () => !disposed && engine === current;
|
||||
current.onPhaseChange = ({ measurement }) => {
|
||||
if (isCurrent()) session.onPhaseChange(isProbe ? 'probe' : measurement.type);
|
||||
};
|
||||
current.onResultsChange = () => {
|
||||
if (isCurrent()) session.onResultsChange();
|
||||
};
|
||||
current.onError = (error) => {
|
||||
if (!isCurrent()) return;
|
||||
failed = true;
|
||||
session.onError(error);
|
||||
};
|
||||
current.onFinish = (results) => {
|
||||
if (!isCurrent()) return;
|
||||
// Flush queued final samples before replacing the engine's result object.
|
||||
session.onResultsChange();
|
||||
if (isProbe && !failed) {
|
||||
detach();
|
||||
isProbe = false;
|
||||
prepare();
|
||||
if (running) engine.play();
|
||||
} else {
|
||||
running = false;
|
||||
session.onFinish(results);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
prepare();
|
||||
return session;
|
||||
};
|
||||
|
After Width: | Height: | Size: 1019 B |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 687 B |
|
After Width: | Height: | Size: 647 B |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 363 B |
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 883 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 590 B |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 131 B |
|
After Width: | Height: | Size: 842 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 910 B |
|
After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 1006 B |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 688 B |
@@ -0,0 +1,127 @@
|
||||
// Verify buffering, late chart loading, phase handoff, and stale async work.
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { markRaw } from 'vue';
|
||||
import useSpeedTestCharts from '../frontend/composables/use-speedtest-charts.js';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const fixture = () => {
|
||||
const pending = [];
|
||||
const rendered = [];
|
||||
const frames = [];
|
||||
let time = 1000;
|
||||
class Chart {
|
||||
static register() {}
|
||||
static getChart() { return null; }
|
||||
constructor(_ctx, config) {
|
||||
Object.assign(this, config);
|
||||
rendered.push(this);
|
||||
}
|
||||
update() {}
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
const module = { Chart, registerables: [] };
|
||||
const charts = useSpeedTestCharts(key => key, {
|
||||
loadChart: () => {
|
||||
const load = deferred();
|
||||
pending.push(load);
|
||||
return load.promise;
|
||||
},
|
||||
scheduleFrame: callback => frames.push(callback),
|
||||
now: () => time,
|
||||
});
|
||||
for (const type of ['download', 'upload', 'latency', 'jitter']) {
|
||||
charts[`${type}Chart`].value = markRaw({ getContext: () => ({
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
}) });
|
||||
}
|
||||
return { charts, pending, rendered, module, frames, setTime: value => { time = value; } };
|
||||
};
|
||||
|
||||
const raw = (count, finished = false) => ({
|
||||
download: { started: true, finished, results: { 100000: { timings: Array.from({ length: count }, () => ({})) } } },
|
||||
});
|
||||
|
||||
test('buffer includes the finished preview and formal samples before Chart.js resolves', async () => {
|
||||
const { charts, pending, rendered, module, setTime } = fixture();
|
||||
const init = charts.initStartingPoints();
|
||||
const sharedInit = charts.initStartingPoints();
|
||||
assert.equal(pending.length, 1);
|
||||
const preview = raw(1, true);
|
||||
setTime(2000);
|
||||
charts.updateCharts(80, 0, 0, 0, preview);
|
||||
charts.updateCharts(80, 0, 0, 0, preview);
|
||||
const formal = raw(0);
|
||||
setTime(3000);
|
||||
charts.updateCharts(80, 0, 0, 0, formal);
|
||||
formal.download.results[100000].timings.push({});
|
||||
setTime(5000);
|
||||
charts.updateCharts(2, 0, 0, 0, formal);
|
||||
assert.deepEqual([...charts.chartData.download.data], [0, 80, 2]);
|
||||
pending[0].resolve(module);
|
||||
await Promise.all([init, sharedInit]);
|
||||
assert.equal(rendered.length, 4);
|
||||
assert.deepEqual(rendered[0].data.datasets[0].data, [0, 80, 2]);
|
||||
assert.deepEqual(rendered[0].data.labels, ['0.0', '1.0', '4.0']);
|
||||
});
|
||||
|
||||
test('loading failure preserves samples and a later load can render them', async () => {
|
||||
const { charts, pending, rendered, module } = fixture();
|
||||
const init = charts.initStartingPoints();
|
||||
charts.updateCharts(3, 0, 0, 0, raw(1));
|
||||
pending[0].reject(new Error('chunk unavailable'));
|
||||
await init;
|
||||
assert.deepEqual([...charts.chartData.download.data], [0, 3]);
|
||||
const retry = charts.initStartingPoints();
|
||||
pending[1].resolve(module);
|
||||
await retry;
|
||||
assert.deepEqual(rendered[0].data.datasets[0].data, [0, 3]);
|
||||
});
|
||||
|
||||
test('restart ignores the old import and its scheduled frames', async () => {
|
||||
const { charts, pending, rendered, module, frames } = fixture();
|
||||
const oldInit = charts.initStartingPoints();
|
||||
charts.updateCharts(99, 0, 0, 0, raw(1));
|
||||
charts.destroyCharts();
|
||||
charts.resetChartData();
|
||||
const newInit = charts.initStartingPoints();
|
||||
charts.updateCharts(5, 0, 0, 0, raw(1));
|
||||
pending[0].resolve(module);
|
||||
await oldInit;
|
||||
assert.equal(rendered.length, 0);
|
||||
const sharedInit = charts.initStartingPoints();
|
||||
assert.equal(pending.length, 2, 'old completion must not clear the current single-flight guard');
|
||||
pending[1].resolve(module);
|
||||
await Promise.all([newInit, sharedInit]);
|
||||
frames.forEach(callback => callback());
|
||||
assert.equal(rendered.length, 4);
|
||||
assert.deepEqual(rendered[0].data.datasets[0].data, [0, 5]);
|
||||
});
|
||||
|
||||
test('unmount during import never creates charts', async () => {
|
||||
const { charts, pending, rendered, module } = fixture();
|
||||
const init = charts.initStartingPoints();
|
||||
charts.destroyCharts();
|
||||
pending[0].resolve(module);
|
||||
await init;
|
||||
assert.equal(rendered.length, 0);
|
||||
});
|
||||
|
||||
test('two latency samples draw jitter and each formal series takes over independently', () => {
|
||||
const { charts } = fixture();
|
||||
const preview = { latency: { started: true, finished: true, results: { timings: [{}, {}] } } };
|
||||
charts.updateCharts(0, 0, 10, 2, preview);
|
||||
const formal = { latency: { started: true, results: { timings: [{}] } } };
|
||||
charts.updateCharts(0, 0, 30, 2, formal);
|
||||
assert.deepEqual([...charts.chartData.latency.data], [10, 30]);
|
||||
assert.deepEqual([...charts.chartData.jitter.data], [2]);
|
||||
formal.latency.results.timings.push({});
|
||||
charts.updateCharts(0, 0, 30, 0, formal);
|
||||
assert.deepEqual([...charts.chartData.jitter.data], [2, 0]);
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
// Exercises `runWithRetry` from frontend/utils/dnsleaks — the single entry
|
||||
// point every DNS leak provider result flows through. Providers are stubbed;
|
||||
// the behaviors under test are the retry loop and the isValidIP gate that
|
||||
// keeps garbage upstream responses away from the MaxMind lookup.
|
||||
// Exercises the runners in frontend/utils/dnsleaks with stubbed providers:
|
||||
// the retry loop and its isValidIP gate (`runWithRetry`), the slot →
|
||||
// standby → neighbour ordering (`buildFallbackChain`) and the chain walk
|
||||
// that reports which provider answered (`runWithFallback`).
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { runWithRetry } from '../frontend/utils/dnsleaks/index.js';
|
||||
import {
|
||||
runWithRetry, buildFallbackChain, runWithFallback,
|
||||
} from '../frontend/utils/dnsleaks/index.js';
|
||||
|
||||
const providerReturning = (results) => {
|
||||
let call = 0;
|
||||
@@ -63,7 +65,7 @@ describe('runWithRetry', () => {
|
||||
() => runWithRetry(provider),
|
||||
/stub: invalid IP in response/,
|
||||
);
|
||||
assert.equal(provider.calls(), 3);
|
||||
assert.equal(provider.calls(), 2);
|
||||
});
|
||||
|
||||
it('rejects a result missing the ip field entirely', async () => {
|
||||
@@ -80,3 +82,98 @@ describe('runWithRetry', () => {
|
||||
assert.equal(provider.calls(), 5);
|
||||
});
|
||||
});
|
||||
|
||||
const provider = (id, results) => ({ ...providerReturning(results), id, name: `${id}.example` });
|
||||
|
||||
describe('buildFallbackChain', () => {
|
||||
const a = provider('a', []);
|
||||
const b = provider('b', []);
|
||||
const c = provider('c', []);
|
||||
const d = provider('d', []);
|
||||
const e = provider('e', []);
|
||||
const registry = [a, b, c, d, e];
|
||||
|
||||
it('orders own provider, then the standbys beyond the slots, then the other slots from 0', () => {
|
||||
assert.deepEqual(buildFallbackChain(1, registry, 3), [b, d, e, a, c]);
|
||||
});
|
||||
|
||||
it('keeps registry order among several standbys', () => {
|
||||
assert.deepEqual(buildFallbackChain(0, registry, 2), [a, c, d, e, b]);
|
||||
});
|
||||
|
||||
it('is just the neighbours when there are no standbys', () => {
|
||||
assert.deepEqual(buildFallbackChain(2, [a, b, c], 3), [c, a, b]);
|
||||
});
|
||||
|
||||
it('is only the provider itself when it is the whole registry', () => {
|
||||
assert.deepEqual(buildFallbackChain(0, [a], 1), [a]);
|
||||
});
|
||||
|
||||
it('returns an empty chain for an index outside the registry', () => {
|
||||
assert.deepEqual(buildFallbackChain(5, registry, 4), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithFallback', () => {
|
||||
it('resolves with the primary and never touches the rest of the chain', async () => {
|
||||
const primary = provider('primary', [{ ip: '8.8.8.8' }]);
|
||||
const spare = provider('spare', [{ ip: '1.1.1.1' }]);
|
||||
const result = await runWithFallback([primary, spare]);
|
||||
assert.deepEqual(result, { ip: '8.8.8.8', provider: primary });
|
||||
assert.equal(spare.calls(), 0);
|
||||
});
|
||||
|
||||
it('falls through to the next provider once the primary exhausts its attempts', async () => {
|
||||
const dead = provider('dead', [new Error('down')]);
|
||||
const spare = provider('spare', [{ ip: '1.1.1.1' }]);
|
||||
const result = await runWithFallback([dead, spare]);
|
||||
assert.equal(result.ip, '1.1.1.1');
|
||||
assert.equal(result.provider, spare);
|
||||
assert.equal(dead.calls(), 2);
|
||||
assert.equal(spare.calls(), 1);
|
||||
});
|
||||
|
||||
it('gives every provider in the chain the default retry budget', async () => {
|
||||
const dead = provider('dead', [new Error('down')]);
|
||||
const flaky = provider('flaky', [{ ip: 'garbage' }, { ip: '9.9.9.9' }]);
|
||||
const result = await runWithFallback([dead, flaky]);
|
||||
assert.equal(result.provider, flaky);
|
||||
assert.equal(dead.calls(), 2);
|
||||
assert.equal(flaky.calls(), 2);
|
||||
});
|
||||
|
||||
it('throws the last error when the whole chain fails', async () => {
|
||||
const first = provider('first', [new Error('first down')]);
|
||||
const last = provider('last', [new Error('last down')]);
|
||||
await assert.rejects(() => runWithFallback([first, last]), /last down/);
|
||||
});
|
||||
|
||||
it('rejects an empty chain', async () => {
|
||||
await assert.rejects(() => runWithFallback([]), /empty provider chain/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider response parsers', () => {
|
||||
it('bash.ws: picks the first entry tagged as a resolver', async () => {
|
||||
const { pickDnsIp } = await import('../frontend/utils/dnsleaks/bashws.js');
|
||||
assert.equal(pickDnsIp([
|
||||
{ ip: '203.0.113.9', type: 'ip' },
|
||||
{ ip: '13.229.187.208', type: 'dns' },
|
||||
{ ip: '13.229.187.209', type: 'dns' },
|
||||
{ ip: 'DNS is not leaking.', type: 'conclusion' },
|
||||
]), '13.229.187.208');
|
||||
assert.equal(pickDnsIp([{ ip: '203.0.113.9', type: 'ip' }]), null);
|
||||
assert.equal(pickDnsIp({ ip: '1.1.1.1' }), null);
|
||||
assert.equal(pickDnsIp(null), null);
|
||||
});
|
||||
|
||||
it('myipstack: extracts the ip between proto and port, IPv6 included', async () => {
|
||||
const { parseRemoteAddress } = await import('../frontend/utils/dnsleaks/myipstack.js');
|
||||
assert.equal(parseRemoteAddress('udp:111.206.4.139:41321'), '111.206.4.139');
|
||||
assert.equal(parseRemoteAddress('tcp:2001:4860:4860::8888:53\n'), '2001:4860:4860::8888');
|
||||
assert.equal(parseRemoteAddress('udp:'), null);
|
||||
assert.equal(parseRemoteAddress('garbage'), null);
|
||||
assert.equal(parseRemoteAddress(''), null);
|
||||
assert.equal(parseRemoteAddress(undefined), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// Verify fixed preview sequencing, formal readings, and session cancellation.
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import CloudflareEngine from '@cloudflare/speedtest';
|
||||
import { createSpeedTestSession, getSpeedTestLiveValues } from '../frontend/utils/speedtest-session.js';
|
||||
|
||||
const packages = () => ({
|
||||
latency: { count: 30 }, download: { bytes: 50e6, count: 4 }, upload: { bytes: 15e6, count: 4 },
|
||||
});
|
||||
|
||||
const fixture = () => {
|
||||
const engines = [];
|
||||
class Engine {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.results = { raw: {} };
|
||||
this.plays = 0;
|
||||
engines.push(this);
|
||||
}
|
||||
play() { this.plays++; }
|
||||
pause() {}
|
||||
finish() { this.onFinish(this.results); }
|
||||
}
|
||||
const config = packages();
|
||||
return { config, engines, session: createSpeedTestSession(Engine, config) };
|
||||
};
|
||||
|
||||
test('preview is fixed and formal settings are captured before the preview starts', () => {
|
||||
const { session, engines, config } = fixture();
|
||||
assert.deepEqual(engines[0].config.measurements, [
|
||||
{ type: 'latency', numPackets: 2 },
|
||||
{ type: 'download', bytes: 1e5, count: 1, bypassMinDuration: true },
|
||||
]);
|
||||
assert.equal(engines[0].config.bandwidthMinRequestDuration, 0);
|
||||
assert.equal(engines[0].config.logAimApiUrl, null);
|
||||
config.download.bytes = 1e6;
|
||||
session.play();
|
||||
const delivered = [];
|
||||
session.onResultsChange = () => delivered.push(session.results);
|
||||
engines[0].finish();
|
||||
assert.equal(delivered.at(-1), engines[0].results, 'flush the preview before switching');
|
||||
assert.equal(session.isProbe, false);
|
||||
assert.equal(engines[1].plays, 1);
|
||||
assert.deepEqual(engines[1].config.measurements, [
|
||||
{ type: 'latency', numPackets: 30 },
|
||||
{ type: 'download', bytes: 50e6, count: 4 },
|
||||
{ type: 'upload', bytes: 15e6, count: 4 },
|
||||
]);
|
||||
assert.equal(session.results, engines[1].results);
|
||||
});
|
||||
|
||||
test('a paused preview cannot auto-start the formal run; resume never repeats the preview', () => {
|
||||
const { session, engines } = fixture();
|
||||
session.play();
|
||||
session.pause();
|
||||
engines[0].finish();
|
||||
assert.equal(engines[1].plays, 0);
|
||||
session.play();
|
||||
session.play();
|
||||
assert.equal(engines.length, 2);
|
||||
assert.equal(engines[1].plays, 1);
|
||||
});
|
||||
|
||||
test('destroy and phase replacement ignore saved callbacks from the old engine', () => {
|
||||
const { session, engines } = fixture();
|
||||
let updates = 0;
|
||||
session.onResultsChange = () => updates++;
|
||||
const oldUpdate = engines[0].onResultsChange;
|
||||
session.play();
|
||||
engines[0].finish();
|
||||
oldUpdate();
|
||||
assert.equal(updates, 1);
|
||||
const lateFinish = engines[1].onFinish;
|
||||
session.destroy();
|
||||
lateFinish(engines[1].results);
|
||||
session.play();
|
||||
assert.equal(updates, 1);
|
||||
assert.equal(engines[1].plays, 1);
|
||||
});
|
||||
|
||||
test('a preview failure does not launch a formal test or report success', () => {
|
||||
const { session, engines } = fixture();
|
||||
let error;
|
||||
session.onError = (value) => { error = value; };
|
||||
session.play();
|
||||
engines[0].onError('connection failed');
|
||||
engines[0].finish();
|
||||
assert.equal(error, 'connection failed');
|
||||
assert.equal(engines.length, 1);
|
||||
});
|
||||
|
||||
test('phase notifications group preview work and ignore phases from an old engine', () => {
|
||||
const { session, engines } = fixture();
|
||||
const phases = [];
|
||||
session.onPhaseChange = phase => phases.push(phase);
|
||||
const oldPhase = engines[0].onPhaseChange;
|
||||
engines[0].onPhaseChange({ measurement: { type: 'latency' } });
|
||||
engines[0].onPhaseChange({ measurement: { type: 'download' } });
|
||||
session.play();
|
||||
engines[0].finish();
|
||||
engines[1].onPhaseChange({ measurement: { type: 'latency' } });
|
||||
oldPhase({ measurement: { type: 'download' } });
|
||||
engines[1].onPhaseChange({ measurement: { type: 'download' } });
|
||||
engines[1].onPhaseChange({ measurement: { type: 'upload' } });
|
||||
assert.deepEqual(phases, ['probe', 'probe', 'latency', 'download', 'upload']);
|
||||
const latePhase = engines[1].onPhaseChange;
|
||||
session.destroy();
|
||||
latePhase({ measurement: { type: 'latency' } });
|
||||
assert.equal(phases.length, 5);
|
||||
});
|
||||
|
||||
test('formal metrics replace preview readings only when their own samples arrive', () => {
|
||||
const values = { downloadSpeed: 90, latency: 12, jitter: 3, uploadSpeed: 0 };
|
||||
const results = {
|
||||
raw: { latency: { results: { timings: [] } }, download: { results: {} } },
|
||||
getDownloadBandwidth: () => 2e6,
|
||||
getUnloadedLatency: () => 30,
|
||||
getUnloadedJitter: () => 0,
|
||||
};
|
||||
Object.assign(values, getSpeedTestLiveValues(results));
|
||||
assert.equal(values.downloadSpeed, 90);
|
||||
assert.equal(values.latency, 12);
|
||||
results.raw.latency.results.timings.push({});
|
||||
Object.assign(values, getSpeedTestLiveValues(results));
|
||||
assert.equal(values.latency, 30);
|
||||
assert.equal(values.jitter, 3);
|
||||
assert.equal(values.downloadSpeed, 90);
|
||||
results.raw.latency.results.timings.push({});
|
||||
results.raw.download.results[50e6] = { timings: [{}] };
|
||||
Object.assign(values, getSpeedTestLiveValues(results));
|
||||
assert.equal(values.jitter, 0);
|
||||
assert.equal(values.downloadSpeed, 2, 'the faster preview does not bias the formal reading');
|
||||
});
|
||||
|
||||
test('installed CF engine delivers a fast preview and all selected formal requests', async (t) => {
|
||||
const requests = [];
|
||||
const timings = new Map();
|
||||
const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { origin: 'https://example.test' } } });
|
||||
Object.defineProperty(globalThis, 'performance', { configurable: true, value: {
|
||||
now: () => 0, clearResourceTimings: () => timings.clear(), setResourceTimingBufferSize: () => {},
|
||||
getEntriesByName: (url) => [timings.get(url)],
|
||||
} });
|
||||
t.after(() => {
|
||||
Object.defineProperty(globalThis, 'performance', originalPerformance);
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else delete globalThis.window;
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options = {}) => {
|
||||
const bytes = Number(new URL(url).searchParams.get('bytes'));
|
||||
requests.push({ bytes, method: options.method || 'GET' });
|
||||
return { ok: true, headers: { get: () => null }, text: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2));
|
||||
timings.set(url, {
|
||||
transferSize: bytes + 300, requestStart: 0, responseStart: 1,
|
||||
responseEnd: bytes === 1e5 ? 2 : bytes ? 2001 : 1,
|
||||
connectStart: 0, connectEnd: 0, secureConnectionStart: 0, nextHopProtocol: 'h2',
|
||||
});
|
||||
return '';
|
||||
} };
|
||||
});
|
||||
class Engine extends CloudflareEngine {
|
||||
constructor(config) {
|
||||
// Exclude side probes and result logging from this deterministic network mock.
|
||||
super({ ...config, measureDownloadLoadedLatency: false, measureUploadLoadedLatency: false, logAimApiUrl: null });
|
||||
}
|
||||
}
|
||||
const session = createSpeedTestSession(Engine, packages());
|
||||
t.after(() => session.destroy());
|
||||
const phases = [];
|
||||
session.onPhaseChange = phase => phases.push(phase);
|
||||
let previewSpeed;
|
||||
session.onResultsChange = () => {
|
||||
if (session.isProbe) previewSpeed = getSpeedTestLiveValues(session.results).downloadSpeed ?? previewSpeed;
|
||||
};
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
session.onFinish = resolve;
|
||||
session.onError = reject;
|
||||
session.play();
|
||||
});
|
||||
assert(previewSpeed > 0, 'a sub-10ms preview still provides a reading');
|
||||
assert.deepEqual(phases, ['probe', 'probe', 'latency', 'download', 'upload']);
|
||||
assert.deepEqual(requests.slice(0, 3).map(item => item.bytes), [0, 0, 1e5]);
|
||||
assert.equal(requests.filter(item => item.bytes === 0).length, 32);
|
||||
assert.equal(requests.filter(item => item.bytes === 50e6).length, 4);
|
||||
assert.equal(requests.filter(item => item.bytes === 15e6).length, 4);
|
||||
assert.equal(result.getUnloadedLatencyPoints().length, 30);
|
||||
assert.equal(result.getDownloadBandwidthPoints().length, 4);
|
||||
assert(result.getDownloadBandwidth() / 1e6 < previewSpeed, 'the final result excludes the preview');
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Exercise the component's startup orchestration with unresolved resource loads.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { reactive, ref, computed, markRaw, nextTick } from 'vue';
|
||||
import { parse } from 'vue/compiler-sfc';
|
||||
import { createSpeedTestSession, getSpeedTestLiveValues } from '../frontend/utils/speedtest-session.js';
|
||||
|
||||
// Run setup logic without mounting a DOM or evaluating UI component imports.
|
||||
const source = readFileSync(new URL('../frontend/components/SpeedTest.vue', import.meta.url), 'utf8');
|
||||
const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, '');
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const fixture = () => {
|
||||
const chartLoad = deferred();
|
||||
const connectionLoads = [];
|
||||
const engines = [];
|
||||
const ips = [];
|
||||
let unmount;
|
||||
let chartLoads = 0;
|
||||
class Engine {
|
||||
constructor() { this.results = { raw: {} }; this.plays = 0; engines.push(this); }
|
||||
play() { this.plays++; }
|
||||
pause() { this.paused = true; }
|
||||
}
|
||||
const bindings = {
|
||||
reactive, ref, computed, markRaw, nextTick,
|
||||
createSpeedTestSession, getSpeedTestLiveValues,
|
||||
onMounted: () => {},
|
||||
onUnmounted: callback => { unmount = callback; },
|
||||
useMainStore: () => ({ lang: 'en', userPreferences: {}, updateAllIPs: value => ips.push(value) }),
|
||||
useI18n: () => ({ t: (key, params) => params ? `${key}: ${params.phase}` : key }),
|
||||
useAppCommand: () => {},
|
||||
trackEvent: () => {},
|
||||
SpeedTestEngine: Engine,
|
||||
useSpeedTestCharts: () => ({
|
||||
updateCharts: () => {}, destroyCharts: () => {}, resetChartData: () => {},
|
||||
initStartingPoints: () => { chartLoads++; return chartLoad.promise; },
|
||||
}),
|
||||
};
|
||||
const setup = new Function(...Object.keys(bindings), `${script}\nreturn { state, speedTestController, connectionMethods, progressLabel };`);
|
||||
const component = setup(...Object.values(bindings));
|
||||
component.connectionMethods.getIPFromSpeedTest = () => {
|
||||
const request = deferred();
|
||||
connectionLoads.push(request);
|
||||
return request.promise;
|
||||
};
|
||||
return { ...component, engines, ips, chartLoad, connectionLoads,
|
||||
unmount: () => unmount(), get chartLoads() { return chartLoads; } };
|
||||
};
|
||||
|
||||
test('starting and pausing work while both resources remain unresolved', { timeout: 1000 }, async () => {
|
||||
const run = fixture();
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.status, 'running');
|
||||
assert.equal(run.engines[0].plays, 1);
|
||||
assert.equal(run.chartLoads, 1);
|
||||
assert.equal(run.connectionLoads.length, 1);
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.status, 'paused');
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.status, 'running');
|
||||
assert.equal(run.engines.length, 1);
|
||||
assert.equal(run.engines[0].plays, 2);
|
||||
run.unmount();
|
||||
});
|
||||
|
||||
test('resource failures do not change a running test to an error', async (t) => {
|
||||
t.mock.method(console, 'error', () => {});
|
||||
const run = fixture();
|
||||
await run.speedTestController();
|
||||
run.chartLoad.reject(new Error('chart unavailable'));
|
||||
run.connectionLoads[0].resolve(null);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(run.state.speedTest.status, 'running');
|
||||
run.unmount();
|
||||
});
|
||||
|
||||
test('progress appears immediately, never regresses across handoff or resume, and resets on retry', async () => {
|
||||
const run = fixture();
|
||||
assert.equal(run.state.speedTest.progress, 0);
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.progress, 5);
|
||||
run.engines[0].onResultsChange();
|
||||
assert.equal(run.state.speedTest.progress, 5);
|
||||
run.engines[0].onFinish(run.engines[0].results);
|
||||
run.engines[1].onResultsChange();
|
||||
assert.equal(run.state.speedTest.progress, 5, 'empty formal results preserve startup progress');
|
||||
run.engines[1].results.raw.latency = { started: true, finished: true };
|
||||
run.engines[1].results.raw.download = { started: true, finished: false };
|
||||
run.engines[1].onResultsChange();
|
||||
const progress = run.state.speedTest.progress;
|
||||
assert(progress > 5);
|
||||
await run.speedTestController();
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.progress, progress);
|
||||
run.state.speedTest.status = 'finished';
|
||||
run.state.speedTest.progress = 100;
|
||||
await run.speedTestController();
|
||||
assert.equal(run.state.speedTest.progress, 5, 'a fresh test starts with fresh progress');
|
||||
run.unmount();
|
||||
});
|
||||
|
||||
test('progress text follows phases and preserves the paused phase', async () => {
|
||||
const run = fixture();
|
||||
await run.speedTestController();
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phases.probe');
|
||||
run.engines[0].onPhaseChange({ measurement: { type: 'download' } });
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phases.probe');
|
||||
run.engines[0].onFinish(run.engines[0].results);
|
||||
run.engines[1].onPhaseChange({ measurement: { type: 'latency' } });
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phases.latency');
|
||||
await run.speedTestController();
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phasePaused: speedtest.phases.latency');
|
||||
await run.speedTestController();
|
||||
run.engines[1].onPhaseChange({ measurement: { type: 'upload' } });
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phases.upload');
|
||||
run.state.speedTest.status = 'finished';
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phaseFinished');
|
||||
run.state.speedTest.status = 'error';
|
||||
assert.equal(run.progressLabel.value, 'speedtest.phaseFailed');
|
||||
run.unmount();
|
||||
});
|
||||
|
||||
test('connection information can arrive later, but stale runs and unmount cannot publish it', async () => {
|
||||
const run = fixture();
|
||||
await run.speedTestController();
|
||||
run.state.speedTest.status = 'error';
|
||||
await run.speedTestController();
|
||||
run.connectionLoads[0].resolve({ ip: '192.0.2.1', loc: 'US', country: 'United States' });
|
||||
await nextTick();
|
||||
assert.equal(run.state.connection.ip, '');
|
||||
run.connectionLoads[1].resolve({ ip: '192.0.2.2', loc: 'SG', country: 'Singapore' });
|
||||
await nextTick();
|
||||
assert.equal(run.state.connection.ip, '192.0.2.2');
|
||||
assert.equal(run.ips.length, 1);
|
||||
run.unmount();
|
||||
|
||||
const abandoned = fixture();
|
||||
await abandoned.speedTestController();
|
||||
abandoned.unmount();
|
||||
abandoned.connectionLoads[0].resolve({ ip: '192.0.2.3' });
|
||||
await nextTick();
|
||||
assert.equal(abandoned.state.connection.ip, '');
|
||||
assert.equal(abandoned.ips.length, 0);
|
||||
});
|
||||