mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(report): emit full test results and collect report snapshots
Every "my network" test now emits a <domain>:finished event with its complete structured result on the app bus (existing thin events keep their fields — achievement rules are untouched). Connectivity and WebRTC additionally record locale-free status/NAT enums next to their localized labels so downstream consumers never parse t() output. A collector mounted once in App.vue normalizes each payload through per-section builders (shape-based cleaning, no free text) and keeps the latest schema-valid snapshot per section for the share dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9621f63496
commit
eae285caf2
17 files changed
+833
-6
No files matched your search
@@ -17,6 +17,7 @@ import Alert from './components/widgets/Toast.vue';
|
||||
import PWA from './components/widgets/PWA.vue';
|
||||
import { useTheme } from '@/composables/use-theme.js';
|
||||
import { useAchievementEngine } from '@/composables/use-achievement-engine.js';
|
||||
import { useReportCollector } from '@/composables/use-report-collector.js';
|
||||
|
||||
// The standalone pages (/tools/:slug, /privacy) carry their own header, so they
|
||||
// drop the homepage's fixed-Nav body padding (see the `body.jn-standalone-page`
|
||||
@@ -65,6 +66,10 @@ useTheme();
|
||||
// Achievement engine: listens for domain events emitted across the app and
|
||||
// evaluates the rules in data/achievement-rules.js.
|
||||
useAchievementEngine();
|
||||
|
||||
// Report collector: keeps the latest schema-shaped snapshot of every finished
|
||||
// test for the shareable diagnostic report.
|
||||
useReportCollector();
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -146,6 +146,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -326,6 +327,10 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
|
||||
clearTimeout(timeoutId);
|
||||
const testTime = Math.round(performance.now() - beginTime);
|
||||
test.status = t('connectivity.StatusAvailable');
|
||||
// Locale-free twin of the localized status label, consumed by the report
|
||||
// builder (utils/report-builders.js) — the label itself can't be mapped
|
||||
// back to an enum without string comparison against t() output.
|
||||
test.statusCode = 'ok';
|
||||
test.mintime = test.mintime === 0 ? testTime : Math.min(test.mintime, testTime);
|
||||
test.time = (multipleTests.value && !isManualRun) ? test.mintime : testTime;
|
||||
if (recordRound) test.roundResults.push({ tone: testTime < 200 ? 'ok-fast' : 'ok-slow', time: testTime });
|
||||
@@ -337,10 +342,12 @@ const checkConnectivityHandler = async (test, onTestComplete = () => { }, isManu
|
||||
// real 'fail' — it's per-round history, separate from the face/text.
|
||||
if (multipleTests.value && !isManualRun && test.mintime > 0) {
|
||||
test.status = t('connectivity.StatusAvailable');
|
||||
test.statusCode = 'ok';
|
||||
test.time = test.mintime;
|
||||
} else {
|
||||
test.time = 0;
|
||||
test.status = t('connectivity.StatusUnavailable');
|
||||
test.statusCode = 'unreachable';
|
||||
}
|
||||
if (recordRound) test.roundResults.push({ tone: 'fail', time: 0 });
|
||||
onTestComplete(false);
|
||||
@@ -354,6 +361,7 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => {
|
||||
if (isRefresh) {
|
||||
connectivityTests.forEach((test) => {
|
||||
test.status = t('connectivity.StatusWait');
|
||||
test.statusCode = undefined;
|
||||
test.time = 0;
|
||||
});
|
||||
trackEvent('Section', 'RefreshClick', 'Connectivity');
|
||||
@@ -381,6 +389,18 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => {
|
||||
updateConnectivityAlert(successCount === totalTests ? 'success' : 'error');
|
||||
// Sticky flag for the Service Status banner — set once the first pass lands.
|
||||
hasEverSettled.value = true;
|
||||
// Domain event: snapshot after every pass (multi-round included) —
|
||||
// latest wins downstream in the report collector.
|
||||
emitAppEvent('connectivity:finished', {
|
||||
targets: connectivityTests.map((test) => ({
|
||||
id: test.id,
|
||||
name: test.name,
|
||||
custom: test.custom === true,
|
||||
statusCode: test.statusCode,
|
||||
time: test.time,
|
||||
mintime: test.mintime,
|
||||
})),
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -255,6 +256,17 @@ const checkAllDNSLeakTest = async (isRefresh) => {
|
||||
store.setLoadingStatus('DNSLeakTest', true);
|
||||
// Local sticky flag for the Enhanced DNS Leak Test banner
|
||||
hasEverSettled.value = true;
|
||||
// Domain event: snapshot for the report collector (cards whose ip slot
|
||||
// still holds a wait/error label are dropped by the builder).
|
||||
emitAppEvent('dnsleak:finished', {
|
||||
providers: leakTest.map((card) => ({
|
||||
id: card.id,
|
||||
name: card.providerName,
|
||||
ip: card.ip,
|
||||
country_code: card.country_code,
|
||||
org: card.org,
|
||||
})),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -223,6 +223,20 @@ const trackFetchStatus = (status) => {
|
||||
}
|
||||
if (allHasFetched) {
|
||||
store.setLoadingStatus('IPInfo', true);
|
||||
// Domain event: full snapshot of the visible cards, re-emitted whenever a
|
||||
// card settles after this point (single-card refresh included). The report
|
||||
// collector normalizes it (drops cards whose ip slot holds an error label).
|
||||
emitAppEvent('ipinfo:finished', {
|
||||
cards: ipDataCards.slice(0, ipCardsToShow.value).map((card) => ({
|
||||
source: card.source,
|
||||
ip: card.ip,
|
||||
country_code: card.country_code,
|
||||
region: card.region,
|
||||
city: card.city,
|
||||
asn: card.asn,
|
||||
isp: card.isp,
|
||||
})),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -452,10 +452,31 @@ const setupTestEngine = async () => {
|
||||
}
|
||||
|
||||
testEngine = null;
|
||||
// Achievement rules for speed thresholds live in data/achievement-rules.js.
|
||||
// Achievement rules for speed thresholds live in data/achievement-rules.js;
|
||||
// the report collector consumes the full result (unmeasured fields keep
|
||||
// their '-' placeholder — the builder drops non-numbers).
|
||||
emitAppEvent('speedtest:finished', {
|
||||
downloadSpeed: state.speedTest.downloadSpeed,
|
||||
uploadSpeed: state.speedTest.uploadSpeed,
|
||||
latency: state.speedTest.latency,
|
||||
jitter: state.speedTest.jitter,
|
||||
downLoadedLatency: state.speedTest.downLoadedLatency,
|
||||
upLoadedLatency: state.speedTest.upLoadedLatency,
|
||||
scores: state.speedTest.hasScores
|
||||
? {
|
||||
streaming: state.speedTest.streamingScore,
|
||||
gaming: state.speedTest.gamingScore,
|
||||
rtc: state.speedTest.rtcScore,
|
||||
}
|
||||
: null,
|
||||
qualities: state.speedTest.hasScores
|
||||
? {
|
||||
streaming: state.speedTest.streamingQuality,
|
||||
gaming: state.speedTest.gamingQuality,
|
||||
rtc: state.speedTest.rtcQuality,
|
||||
}
|
||||
: null,
|
||||
connection: { ...state.connection },
|
||||
});
|
||||
};
|
||||
testEngine.onError = (e) => {
|
||||
|
||||
@@ -120,6 +120,7 @@ import { ref, computed, onMounted, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -231,6 +232,8 @@ const checkSTUNServer = (stun) => {
|
||||
const label = t(`webrtc.${statusKey}`);
|
||||
stun.ip = label;
|
||||
stun.natType = label;
|
||||
// Locale-free twin of the label, consumed by the report builder.
|
||||
stun.natTypeCode = statusKey === 'StatusUnavailable' ? 'unavailable' : 'error';
|
||||
stun.country = label;
|
||||
stun.country_code = '';
|
||||
stun.org = label;
|
||||
@@ -249,6 +252,7 @@ const checkSTUNServer = (stun) => {
|
||||
}
|
||||
stun.ip = ip;
|
||||
stun.natType = determineNATType(candidate);
|
||||
stun.natTypeCode = natTypeCodeOf(candidate);
|
||||
log(`resolved IP: ${ip}`);
|
||||
IPArray.value = [...IPArray.value, { ip, country: '' }];
|
||||
// useMaxmind swallows its own errors and returns null on miss, so
|
||||
@@ -358,6 +362,27 @@ const determineNATType = (candidate) => {
|
||||
return t('webrtc.NATType.unknown');
|
||||
};
|
||||
|
||||
// Locale-free twin of determineNATType for the report builder.
|
||||
const natTypeCodeOf = (candidate) => {
|
||||
const type = candidate.split(' ')[7];
|
||||
return ['host', 'srflx', 'prflx', 'relay'].includes(type) ? type : 'unknown';
|
||||
};
|
||||
|
||||
// Domain event: snapshot of all STUN cards for the report collector (servers
|
||||
// still waiting carry no natTypeCode and are dropped by the builder).
|
||||
const emitWebrtcFinished = () => {
|
||||
emitAppEvent('webrtc:finished', {
|
||||
servers: stunServers.map((server) => ({
|
||||
id: server.id,
|
||||
url: server.url,
|
||||
ip: server.ip,
|
||||
natTypeCode: server.natTypeCode,
|
||||
country_code: server.country_code,
|
||||
org: server.org,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
// Test all STUN servers
|
||||
const checkAllWebRTC = async (isRefresh) => {
|
||||
if (isRefresh) trackEvent('Section', 'RefreshClick', 'WebRTC');
|
||||
@@ -371,17 +396,20 @@ const checkAllWebRTC = async (isRefresh) => {
|
||||
stunServers.forEach((server) => {
|
||||
server.ip = label;
|
||||
server.natType = label;
|
||||
server.natTypeCode = 'unavailable';
|
||||
server.country = label;
|
||||
server.country_code = '';
|
||||
server.org = label;
|
||||
});
|
||||
store.setLoadingStatus('WebRTC', true);
|
||||
emitWebrtcFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = stunServers.map((server) => {
|
||||
server.ip = t('webrtc.StatusWait');
|
||||
server.natType = t('webrtc.StatusWait');
|
||||
server.natTypeCode = undefined;
|
||||
server.country = t('webrtc.StatusWait');
|
||||
server.country_code = '';
|
||||
server.org = t('webrtc.StatusWait');
|
||||
@@ -392,6 +420,7 @@ const checkAllWebRTC = async (isRefresh) => {
|
||||
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
return Promise.race([allSettledPromise, timeoutPromise]).then(() => {
|
||||
store.setLoadingStatus('WebRTC', true);
|
||||
emitWebrtcFinished();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
@@ -351,6 +352,12 @@ const getAll = async () => {
|
||||
checkingStatus.value = 'running';
|
||||
await Promise.all([getUA(), getFingerPrint(), getGPU(), getOtherBrowserInfo()]);
|
||||
checkingStatus.value = 'finished';
|
||||
// Domain event for the report collector. Deliberately excludes the
|
||||
// fingerprint hash and components blob — they never enter a report.
|
||||
emitAppEvent('browserinfo:finished', {
|
||||
userAgent: userAgent.value,
|
||||
otherInfos: otherInfos.value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during checks:', error);
|
||||
checkingStatus.value = 'error';
|
||||
|
||||
@@ -258,6 +258,7 @@ import { ref, reactive, computed } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { authenticatedFetch } from '@/utils/authenticated-fetch';
|
||||
import getCountryName from '@/data/country-name.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -563,6 +564,9 @@ const runTest = async () => {
|
||||
queries: Array.isArray(data.queries) ? data.queries : [],
|
||||
};
|
||||
status.value = 'done';
|
||||
// Domain event: raw query rows for the report collector (the builder
|
||||
// maps geo sub-objects and derives the DNSSEC posture).
|
||||
emitAppEvent('enhanceddnsleak:finished', { ...result.value });
|
||||
} catch (error) {
|
||||
console.error('Enhanced DNS leak test failed:', error);
|
||||
if (error.message.includes('Invalid token')) {
|
||||
|
||||
@@ -127,6 +127,7 @@ import { ref, computed } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { useGlobalpingMeasurement, GLOBALPING_DEFAULT_LOCATIONS, selectableIPs } from '@/composables/use-globalping-measurement';
|
||||
import { isValidIP } from '@/utils/valid-ip.js';
|
||||
import getCountryName from '@/data/country-name.js';
|
||||
@@ -205,7 +206,17 @@ const startPingCheck = () => {
|
||||
processpingResults(data);
|
||||
return pingResults.value.length > 0;
|
||||
},
|
||||
onFinish: () => drawMap(),
|
||||
onFinish: () => {
|
||||
drawMap();
|
||||
// Domain event: final probe results for the report collector.
|
||||
emitAppEvent('pingtest:finished', {
|
||||
target: targetIP.value,
|
||||
probes: pingResults.value.map((result) => ({
|
||||
country: result.country,
|
||||
stats: result.stats,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -302,10 +302,13 @@ const getResult = async () => {
|
||||
errorMsg.value = t('invisibilitytest.fetchError');
|
||||
} else {
|
||||
testResults.value = data;
|
||||
// Achievement rules (HiddenWell / SlipUp) live in data/achievement-rules.js.
|
||||
// Achievement rules (HiddenWell / SlipUp) live in data/achievement-rules.js;
|
||||
// the report collector consumes ip + the 14 detection flags.
|
||||
emitAppEvent('invisibility:result', {
|
||||
proxyScore: Math.floor(data.score.proxy),
|
||||
vpnScore: Math.floor(data.score.vpn),
|
||||
ip: data.ip,
|
||||
flags: detectionItems.value.map(({ key, flagged }) => ({ key, flagged })),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -147,6 +147,7 @@ import { ref, computed } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { useGlobalpingMeasurement, GLOBALPING_DEFAULT_LOCATIONS, selectableIPs } from '@/composables/use-globalping-measurement';
|
||||
import { isValidIP } from '@/utils/valid-ip.js';
|
||||
import { parseMtrOutput } from '@/utils/mtr-parse.js';
|
||||
@@ -205,6 +206,21 @@ const startmtrCheck = () => {
|
||||
processmtrResults(data);
|
||||
return mtrResults.value.length > 0;
|
||||
},
|
||||
onFinish: () => {
|
||||
// Domain event: final probes with parsed hops for the report
|
||||
// collector (rawOutput stays local — only the structured hops
|
||||
// belong in a shareable report).
|
||||
emitAppEvent('mtrtest:finished', {
|
||||
target: targetIP.value,
|
||||
probes: mtrResults.value.map((result) => ({
|
||||
country: result.country,
|
||||
city: result.city,
|
||||
network: result.network,
|
||||
asn: result.asn,
|
||||
hops: result.hops,
|
||||
})),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -200,9 +200,17 @@ const checkAllRuleTest = async (refresh = false) => {
|
||||
processTest(index + 1);
|
||||
if (index === testCount.value - 1) {
|
||||
finishAll.value = true;
|
||||
// Achievement rule (CrossingTheWall) lives in data/achievement-rules.js.
|
||||
// Achievement rule (CrossingTheWall) lives in data/achievement-rules.js;
|
||||
// the report collector consumes the per-worker details (the
|
||||
// builder recomputes its own unique count from valid IPs).
|
||||
emitAppEvent('ruletest:finished', {
|
||||
uniqueIPCount: new Set(ruleTests.value.map((test) => test.ip)).size,
|
||||
workers: ruleTests.value.map((test) => ({
|
||||
id: test.id,
|
||||
ip: test.ip,
|
||||
country_code: test.country_code,
|
||||
org: test.org,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Report collector — subscribes to the test-completion events on the app bus
|
||||
// (utils/app-events.js), runs each payload through its report builder
|
||||
// (utils/report-builders.js) and keeps the latest schema-shaped snapshot per
|
||||
// section. This is the data source for the share-report dialog; components
|
||||
// stay report-agnostic, exactly like the achievement engine's pattern.
|
||||
// Call once from App.vue setup.
|
||||
|
||||
import { reactive, computed, onScopeDispose } from 'vue';
|
||||
import { onAppEvent } from '../utils/app-events.js';
|
||||
import { REPORT_EVENT_BUILDERS } from '../utils/report-builders.js';
|
||||
|
||||
// Module-level so later consumers (share dialog) see the same snapshots the
|
||||
// App.vue-mounted subscriber collected. Shape: sectionId → schema section
|
||||
// object (testedAt included).
|
||||
const collectedSections = reactive({});
|
||||
|
||||
// Latest-wins: every re-run of a test overwrites its section snapshot.
|
||||
const ingest = (event, payload) => {
|
||||
const { section, build } = REPORT_EVENT_BUILDERS[event];
|
||||
const built = build(payload);
|
||||
if (built === null) return;
|
||||
collectedSections[section] = { testedAt: new Date().toISOString(), ...built };
|
||||
};
|
||||
|
||||
export const useReportCollector = () => {
|
||||
const unsubscribes = Object.keys(REPORT_EVENT_BUILDERS).map((event) =>
|
||||
onAppEvent(event, (payload) => ingest(event, payload)),
|
||||
);
|
||||
|
||||
// App.vue never unmounts in production; this keeps HMR re-runs of setup
|
||||
// from stacking duplicate listeners in dev.
|
||||
onScopeDispose(() => unsubscribes.forEach((unsubscribe) => unsubscribe()));
|
||||
};
|
||||
|
||||
// Read-only access for the share dialog: the raw snapshots plus the list of
|
||||
// sections that currently hold data (in no particular order — display order
|
||||
// comes from REPORT_SECTION_IDS in the schema).
|
||||
export const useCollectedReport = () => ({
|
||||
sections: collectedSections,
|
||||
availableSectionIds: computed(() => Object.keys(collectedSections)),
|
||||
});
|
||||
|
||||
// Drop every collected snapshot (module state is shared — used by tests and
|
||||
// available to a future "clear my data" control).
|
||||
export const resetCollectedReport = () => {
|
||||
for (const key of Object.keys(collectedSections)) delete collectedSections[key];
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
// Minimal app-wide event bus for domain events — components emit what
|
||||
// happened ("speed test finished", "whois lookup ran") and decoupled modules
|
||||
// react to it. Framework-agnostic on purpose: no Vue imports, usable from any
|
||||
// util / composable / component. Currently consumed by the achievement engine
|
||||
// (composables/use-achievement-engine.js).
|
||||
// util / composable / component. Consumed by the achievement engine
|
||||
// (composables/use-achievement-engine.js) and the report collector
|
||||
// (composables/use-report-collector.js).
|
||||
|
||||
const listeners = new Map(); // event name → Set<handler>
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Builders that turn the domain events emitted by test components into
|
||||
// report-schema sections (see common/report-schema.js). Components emit raw
|
||||
// snapshots of their own state — including wait/error placeholder strings —
|
||||
// and every normalization rule here is shape-based (is it a valid IP? was a
|
||||
// geo lookup recorded?), never a comparison against localized text. Each
|
||||
// builder returns a schema-shaped section object, or null when the payload
|
||||
// holds nothing worth reporting.
|
||||
|
||||
import { isValidIP } from './valid-ip.js';
|
||||
|
||||
// --- small shared normalizers ----------------------------------------------
|
||||
|
||||
const clip = (value, max) =>
|
||||
typeof value === 'string' ? value.slice(0, max) : undefined;
|
||||
|
||||
const finiteNum = (value, min, max) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max
|
||||
? value : undefined;
|
||||
|
||||
const clampInt = (value, min, max) => {
|
||||
if (!Number.isInteger(value)) return undefined;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
};
|
||||
|
||||
const countryCode = (value) =>
|
||||
typeof value === 'string' && /^[a-z]{2}$/i.test(value) ? value.toUpperCase() : '';
|
||||
|
||||
const ipOrUndefined = (value) => (isValidIP(value) ? value : undefined);
|
||||
|
||||
// Drop undefined values so optional schema keys are absent, not null.
|
||||
const compact = (obj) => {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// --- per-section builders ---------------------------------------------------
|
||||
|
||||
// ipinfo:finished — { cards: [{ source, ip, country_code, region, city, asn, isp }] }
|
||||
// Cards whose ip never resolved (placeholder text in the slot) are dropped.
|
||||
const buildIpinfo = (payload) => {
|
||||
const cards = (payload?.cards ?? [])
|
||||
.filter((card) => isValidIP(card?.ip))
|
||||
.slice(0, 8)
|
||||
.map((card) => compact({
|
||||
source: clip(card.source, 64) ?? '',
|
||||
ip: card.ip,
|
||||
countryCode: countryCode(card.country_code),
|
||||
region: clip(card.region, 64),
|
||||
city: clip(card.city, 64),
|
||||
asn: clip(card.asn, 16),
|
||||
isp: clip(card.isp, 128),
|
||||
}));
|
||||
return cards.length ? { cards } : null;
|
||||
};
|
||||
|
||||
// connectivity:finished — { targets: [{ id, name, custom, statusCode, time, mintime }] }
|
||||
// statusCode is the locale-free enum the component records next to its
|
||||
// localized status label; targets still waiting carry none and are dropped.
|
||||
const CONNECTIVITY_STATUSES = new Set(['ok', 'unreachable', 'timeout']);
|
||||
const buildConnectivity = (payload) => {
|
||||
const targets = (payload?.targets ?? [])
|
||||
.filter((target) => CONNECTIVITY_STATUSES.has(target?.statusCode))
|
||||
.slice(0, 24)
|
||||
.map((target) => compact({
|
||||
id: clip(target.id, 48) ?? '',
|
||||
name: clip(target.name, 64) ?? '',
|
||||
status: target.statusCode,
|
||||
timeMs: clampInt(target.time, 0, 600000),
|
||||
minTimeMs: clampInt(target.mintime, 0, 600000),
|
||||
custom: target.custom === true ? true : undefined,
|
||||
}));
|
||||
return targets.length ? { targets } : null;
|
||||
};
|
||||
|
||||
// webrtc:finished — { servers: [{ id, url, ip, natTypeCode, country_code, org }] }
|
||||
// org is only trusted when a geo lookup landed (country_code recorded with it).
|
||||
const NAT_TYPES = new Set(['host', 'srflx', 'prflx', 'relay', 'unknown', 'unavailable', 'error']);
|
||||
const buildWebrtc = (payload) => {
|
||||
const servers = (payload?.servers ?? [])
|
||||
.filter((server) => NAT_TYPES.has(server?.natTypeCode))
|
||||
.slice(0, 8)
|
||||
.map((server) => {
|
||||
const cc = countryCode(server.country_code);
|
||||
return compact({
|
||||
id: clip(server.id, 32) ?? '',
|
||||
url: clip(server.url, 128) ?? '',
|
||||
ip: ipOrUndefined(server.ip),
|
||||
natType: server.natTypeCode,
|
||||
countryCode: cc,
|
||||
org: cc ? clip(server.org, 128) : undefined,
|
||||
});
|
||||
});
|
||||
return servers.length ? { servers } : null;
|
||||
};
|
||||
|
||||
// dnsleak:finished — { providers: [{ id, name, ip, country_code, org }] }
|
||||
const buildDnsleak = (payload) => {
|
||||
const providers = (payload?.providers ?? [])
|
||||
.filter((provider) => isValidIP(provider?.ip))
|
||||
.slice(0, 8)
|
||||
.map((provider) => {
|
||||
const cc = countryCode(provider.country_code);
|
||||
return compact({
|
||||
id: clip(provider.id, 32) ?? '',
|
||||
name: clip(provider.name, 64) ?? '',
|
||||
ip: provider.ip,
|
||||
countryCode: cc,
|
||||
org: cc ? clip(provider.org, 128) : undefined,
|
||||
});
|
||||
});
|
||||
return providers.length ? { providers } : null;
|
||||
};
|
||||
|
||||
// speedtest:finished — thickened payload; numeric fields may be '-' until
|
||||
// measured, which finiteNum turns into an absent key.
|
||||
const QUALITIES = new Set(['bad', 'poor', 'average', 'good', 'great']);
|
||||
const quality = (value) => (QUALITIES.has(value) ? value : undefined);
|
||||
const buildSpeedtest = (payload) => {
|
||||
if (!payload) return null;
|
||||
const section = compact({
|
||||
downloadMbps: finiteNum(payload.downloadSpeed, 0, 1000000),
|
||||
uploadMbps: finiteNum(payload.uploadSpeed, 0, 1000000),
|
||||
latencyMs: finiteNum(payload.latency, 0, 600000),
|
||||
jitterMs: finiteNum(payload.jitter, 0, 600000),
|
||||
loadedLatencyDownMs: finiteNum(payload.downLoadedLatency, 0, 600000),
|
||||
loadedLatencyUpMs: finiteNum(payload.upLoadedLatency, 0, 600000),
|
||||
});
|
||||
if (Object.keys(section).length === 0) return null;
|
||||
|
||||
const scores = compact({
|
||||
streaming: clampInt(payload.scores?.streaming, 0, 10000),
|
||||
gaming: clampInt(payload.scores?.gaming, 0, 10000),
|
||||
rtc: clampInt(payload.scores?.rtc, 0, 10000),
|
||||
});
|
||||
if (Object.keys(scores).length) section.scores = scores;
|
||||
|
||||
const qualities = compact({
|
||||
streaming: quality(payload.qualities?.streaming),
|
||||
gaming: quality(payload.qualities?.gaming),
|
||||
rtc: quality(payload.qualities?.rtc),
|
||||
});
|
||||
if (Object.keys(qualities).length) section.qualities = qualities;
|
||||
|
||||
const connection = compact({
|
||||
ip: ipOrUndefined(payload.connection?.ip),
|
||||
colo: clip(payload.connection?.colo, 8),
|
||||
coloCountryCode: countryCode(payload.connection?.coloCountryCode),
|
||||
coloCity: clip(payload.connection?.coloCity, 64),
|
||||
});
|
||||
if (Object.keys(connection).length) section.connection = connection;
|
||||
|
||||
return section;
|
||||
};
|
||||
|
||||
// pingtest:finished — { target, probes: [{ country, stats }] }. Globalping
|
||||
// stats can carry extra keys (mdev, …) — only the whitelisted ones survive.
|
||||
const buildPingtest = (payload) => {
|
||||
const target = clip(payload?.target, 253);
|
||||
const probes = (payload?.probes ?? [])
|
||||
.slice(0, 32)
|
||||
.map((probe) => ({
|
||||
countryCode: countryCode(probe?.country),
|
||||
stats: compact({
|
||||
min: finiteNum(probe?.stats?.min, 0, 600000),
|
||||
max: finiteNum(probe?.stats?.max, 0, 600000),
|
||||
avg: finiteNum(probe?.stats?.avg, 0, 600000),
|
||||
loss: finiteNum(probe?.stats?.loss, 0, 100),
|
||||
total: clampInt(probe?.stats?.total, 0, 1000),
|
||||
rcv: clampInt(probe?.stats?.rcv, 0, 1000),
|
||||
drop: clampInt(probe?.stats?.drop, 0, 1000),
|
||||
}),
|
||||
}));
|
||||
return target && probes.length ? { target, probes } : null;
|
||||
};
|
||||
|
||||
// mtrtest:finished — { target, probes: [{ country, city, network, asn, hops }] }
|
||||
// hops come pre-structured from parseMtrOutput and already fit the schema.
|
||||
const buildMtrtest = (payload) => {
|
||||
const target = clip(payload?.target, 253);
|
||||
const probes = (payload?.probes ?? [])
|
||||
.slice(0, 32)
|
||||
.map((probe) => compact({
|
||||
countryCode: countryCode(probe?.country),
|
||||
city: clip(probe?.city, 64),
|
||||
network: clip(probe?.network, 128),
|
||||
asn: clampInt(probe?.asn, 0, 4294967295),
|
||||
hops: (probe?.hops ?? []).slice(0, 64).map((hop) => compact({
|
||||
...hop,
|
||||
host: clip(hop.host, 128),
|
||||
ip: ipOrUndefined(hop.ip),
|
||||
})),
|
||||
}))
|
||||
.filter((probe) => probe.hops.length);
|
||||
return target && probes.length ? { target, probes } : null;
|
||||
};
|
||||
|
||||
// ruletest:finished — { uniqueIPCount, workers: [{ id, ip, country_code, org }] }
|
||||
// uniqueIPCount is recomputed from valid IPs only — the event's own count
|
||||
// (kept for achievement rules) also counts error placeholders as "IPs".
|
||||
const buildRuletest = (payload) => {
|
||||
const workers = (payload?.workers ?? [])
|
||||
.filter((worker) => isValidIP(worker?.ip))
|
||||
.slice(0, 16)
|
||||
.map((worker) => compact({
|
||||
id: clampInt(worker.id, 1, 16) ?? 1,
|
||||
ip: worker.ip,
|
||||
countryCode: countryCode(worker.country_code),
|
||||
org: clip(worker.org, 128),
|
||||
}));
|
||||
if (!workers.length) return null;
|
||||
return {
|
||||
uniqueIPCount: new Set(workers.map((worker) => worker.ip)).size,
|
||||
workers,
|
||||
};
|
||||
};
|
||||
|
||||
// browserinfo:finished — { userAgent, otherInfos } (ua-parser result + the
|
||||
// component's otherInfos block). Fingerprint data is deliberately never
|
||||
// forwarded by the component.
|
||||
const nameVersion = (part) => {
|
||||
const picked = compact({ name: clip(part?.name, 64), version: clip(part?.version, 32) });
|
||||
return Object.keys(picked).length ? picked : undefined;
|
||||
};
|
||||
const buildBrowserinfo = (payload) => {
|
||||
const ua = payload?.userAgent;
|
||||
const other = payload?.otherInfos;
|
||||
if (!ua && !other) return null;
|
||||
const connection = other?.connection
|
||||
? compact({
|
||||
effectiveType: clip(other.connection.effectiveType, 16),
|
||||
downlink: finiteNum(other.connection.downlink, 0, 100000),
|
||||
rtt: clampInt(other.connection.rtt, 0, 600000),
|
||||
})
|
||||
: undefined;
|
||||
const section = compact({
|
||||
browser: nameVersion(ua?.browser),
|
||||
os: nameVersion(ua?.os),
|
||||
engine: nameVersion(ua?.engine),
|
||||
timezone: clip(other?.timezone, 64),
|
||||
languages: Array.isArray(other?.languages)
|
||||
? other.languages.slice(0, 16).map((l) => clip(l, 16) ?? '')
|
||||
: undefined,
|
||||
display: other?.display
|
||||
? compact({
|
||||
width: clampInt(other.display.width, 0, 100000),
|
||||
height: clampInt(other.display.height, 0, 100000),
|
||||
pixelRatio: finiteNum(other.display.pixelRatio, 0, 100),
|
||||
})
|
||||
: undefined,
|
||||
connection,
|
||||
});
|
||||
return Object.keys(section).length ? section : null;
|
||||
};
|
||||
|
||||
// invisibility:result — { proxyScore, vpnScore, ip, flags: [{ key, flagged }] }
|
||||
const buildInvisibility = (payload) => {
|
||||
const proxy = finiteNum(payload?.proxyScore, 0, 100);
|
||||
const vpn = finiteNum(payload?.vpnScore, 0, 100);
|
||||
if (proxy === undefined || vpn === undefined) return null;
|
||||
return compact({
|
||||
ip: ipOrUndefined(payload.ip),
|
||||
scores: { proxy, vpn },
|
||||
flags: (payload?.flags ?? []).slice(0, 20).map((flag) => ({
|
||||
key: clip(flag?.key, 32) ?? '',
|
||||
flagged: flag?.flagged === true,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
// enhanceddnsleak:finished — { rawCount, resolverCount, queries } with the
|
||||
// upstream's raw query rows; the DNSSEC posture is derived here (worst-of,
|
||||
// same rule as the component's dnssecSummary).
|
||||
const buildEnhanceddnsleak = (payload) => {
|
||||
const rows = Array.isArray(payload?.queries) ? payload.queries : [];
|
||||
const queries = rows
|
||||
.filter((row) => isValidIP(row?.ip))
|
||||
.slice(0, 128)
|
||||
.map((row) => compact({
|
||||
ip: row.ip,
|
||||
countryCode: countryCode(row.ipInfo?.country_code),
|
||||
asn: row.ipInfo?.asn != null ? clip(String(row.ipInfo.asn), 16) : undefined,
|
||||
org: clip(row.ipInfo?.org, 128),
|
||||
transport: clip(row.transport, 16),
|
||||
ecs: clip(row.ecs, 64),
|
||||
do: row.do === true,
|
||||
cd: row.cd === true,
|
||||
}));
|
||||
if (!queries.length) return null;
|
||||
const dnssec = queries.some((q) => q.cd) || queries.some((q) => !q.do) ? 'partial' : 'ok';
|
||||
return {
|
||||
rawCount: clampInt(payload.rawCount, 0, 100000) ?? 0,
|
||||
resolverCount: clampInt(payload.resolverCount, 0, 10000) ?? 0,
|
||||
dnssec,
|
||||
queries,
|
||||
};
|
||||
};
|
||||
|
||||
// --- event → builder registry (consumed by use-report-collector) ------------
|
||||
|
||||
export const REPORT_EVENT_BUILDERS = {
|
||||
'ipinfo:finished': { section: 'ipinfo', build: buildIpinfo },
|
||||
'connectivity:finished': { section: 'connectivity', build: buildConnectivity },
|
||||
'webrtc:finished': { section: 'webrtc', build: buildWebrtc },
|
||||
'dnsleak:finished': { section: 'dnsleak', build: buildDnsleak },
|
||||
'speedtest:finished': { section: 'speedtest', build: buildSpeedtest },
|
||||
'pingtest:finished': { section: 'pingtest', build: buildPingtest },
|
||||
'mtrtest:finished': { section: 'mtrtest', build: buildMtrtest },
|
||||
'ruletest:finished': { section: 'ruletest', build: buildRuletest },
|
||||
'browserinfo:finished': { section: 'browserinfo', build: buildBrowserinfo },
|
||||
'invisibility:result': { section: 'invisibility', build: buildInvisibility },
|
||||
'enhanceddnsleak:finished': { section: 'enhanceddnsleak', build: buildEnhanceddnsleak },
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// Tests for the report collector (frontend/composables/use-report-collector.js):
|
||||
// event-driven accumulation of schema-shaped section snapshots, latest-wins
|
||||
// overwrites, null-builder payloads ignored, and unsubscribe on scope dispose.
|
||||
// Unlike the achievement engine it needs no Pinia store — just Vue reactivity.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import { effectScope } from 'vue';
|
||||
|
||||
import {
|
||||
useReportCollector,
|
||||
useCollectedReport,
|
||||
resetCollectedReport,
|
||||
} from '../frontend/composables/use-report-collector.js';
|
||||
import { emitAppEvent } from '../frontend/utils/app-events.js';
|
||||
|
||||
const SPEEDTEST_PAYLOAD = {
|
||||
downloadSpeed: 100, uploadSpeed: 20, latency: 15, jitter: 2,
|
||||
downLoadedLatency: 40, upLoadedLatency: 60,
|
||||
scores: null, qualities: null,
|
||||
connection: { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK', coloCity: 'Hong Kong' },
|
||||
};
|
||||
|
||||
let scope;
|
||||
|
||||
beforeEach(() => {
|
||||
resetCollectedReport();
|
||||
scope = effectScope();
|
||||
scope.run(() => useReportCollector());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop();
|
||||
resetCollectedReport();
|
||||
});
|
||||
|
||||
describe('report collector', () => {
|
||||
it('stores a schema-shaped snapshot with testedAt when a test finishes', () => {
|
||||
emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD);
|
||||
const { sections, availableSectionIds } = useCollectedReport();
|
||||
assert.deepEqual(availableSectionIds.value, ['speedtest']);
|
||||
assert.equal(sections.speedtest.downloadMbps, 100);
|
||||
assert.equal(Number.isNaN(Date.parse(sections.speedtest.testedAt)), false);
|
||||
});
|
||||
|
||||
it('latest emit wins', () => {
|
||||
emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD);
|
||||
emitAppEvent('speedtest:finished', { ...SPEEDTEST_PAYLOAD, downloadSpeed: 250 });
|
||||
const { sections } = useCollectedReport();
|
||||
assert.equal(sections.speedtest.downloadMbps, 250);
|
||||
});
|
||||
|
||||
it('ignores payloads the builder rejects, keeping the previous snapshot', () => {
|
||||
emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD);
|
||||
emitAppEvent('speedtest:finished', {}); // builder → null
|
||||
const { sections } = useCollectedReport();
|
||||
assert.equal(sections.speedtest.downloadMbps, 100);
|
||||
});
|
||||
|
||||
it('ignores unrelated bus events entirely', () => {
|
||||
emitAppEvent('whois:lookup', { query: 'example.com' });
|
||||
emitAppEvent('ip-source:exhausted', { source: 'ipip', ipVersion: 'v4' });
|
||||
const { availableSectionIds } = useCollectedReport();
|
||||
assert.deepEqual(availableSectionIds.value, []);
|
||||
});
|
||||
|
||||
it('accumulates independent sections side by side', () => {
|
||||
emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD);
|
||||
emitAppEvent('ruletest:finished', {
|
||||
uniqueIPCount: 1,
|
||||
workers: [{ id: 1, ip: '1.2.3.4', country_code: 'US', org: 'CF' }],
|
||||
});
|
||||
const { availableSectionIds } = useCollectedReport();
|
||||
assert.deepEqual([...availableSectionIds.value].sort(), ['ruletest', 'speedtest']);
|
||||
});
|
||||
|
||||
it('stops listening once its scope is disposed', () => {
|
||||
scope.stop();
|
||||
emitAppEvent('speedtest:finished', SPEEDTEST_PAYLOAD);
|
||||
const { availableSectionIds } = useCollectedReport();
|
||||
assert.deepEqual(availableSectionIds.value, []);
|
||||
// beforeEach of the next test re-creates a scope; afterEach's stop
|
||||
// on an already-stopped scope is a no-op.
|
||||
scope = effectScope();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// Exercises the report builders (frontend/utils/report-builders.js): every
|
||||
// builder's output — wrapped in a report envelope — must pass the shared
|
||||
// schema validator, and the shape-based cleaning rules (invalid IPs dropped,
|
||||
// geo-less org distrusted, stat whitelists) must hold.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { REPORT_EVENT_BUILDERS } from '../frontend/utils/report-builders.js';
|
||||
import { validateReport, REPORT_VERSION } from '../common/report-schema.js';
|
||||
|
||||
const wrap = (sectionId, section) => ({
|
||||
v: REPORT_VERSION,
|
||||
generatedAt: '2026-07-14T08:00:00.000Z',
|
||||
origin: 'ipcheck.ing',
|
||||
locale: 'zh',
|
||||
sections: { [sectionId]: { testedAt: '2026-07-14T08:00:00.000Z', ...section } },
|
||||
});
|
||||
|
||||
// Realistic payloads mirroring what each component actually emits — including
|
||||
// localized placeholder strings in ip slots, which the builders must drop.
|
||||
const PAYLOADS = {
|
||||
'ipinfo:finished': {
|
||||
cards: [
|
||||
{ source: 'IPCheck.ing IPv4', ip: '1.2.3.4', country_code: 'us', region: 'CA', city: 'SF', asn: 'AS15169', isp: 'Google LLC' },
|
||||
{ source: 'IPCheck.ing IPv6', ip: 'IPv6 检测失败' },
|
||||
{ source: 'Cloudflare IPv6', ip: '2001:db8::1', country_code: '' },
|
||||
],
|
||||
},
|
||||
'connectivity:finished': {
|
||||
targets: [
|
||||
{ id: 'google', name: 'Google', custom: false, statusCode: 'ok', time: 123, mintime: 98 },
|
||||
{ id: 'wechat', name: 'WeChat', custom: false, statusCode: 'unreachable', time: 0, mintime: 0 },
|
||||
{ id: 'custom-1', name: 'My server', custom: true, statusCode: undefined, time: 0, mintime: 0 },
|
||||
],
|
||||
},
|
||||
'webrtc:finished': {
|
||||
servers: [
|
||||
{ id: 'google', url: 'stun.l.google.com:19302', ip: '1.2.3.4', natTypeCode: 'srflx', country_code: 'US', org: 'Google' },
|
||||
{ id: 'twilio', url: 'global.stun.twilio.com', ip: 'STUN 请求失败', natTypeCode: 'error', country_code: '', org: 'STUN 请求失败' },
|
||||
{ id: 'cloudflare', url: 'stun.cloudflare.com', ip: '等待检测', natTypeCode: undefined, country_code: '', org: '等待检测' },
|
||||
],
|
||||
},
|
||||
'dnsleak:finished': {
|
||||
providers: [
|
||||
{ id: 'surfshark', name: 'Surfshark', ip: '8.8.4.4', country_code: 'US', org: 'Google' },
|
||||
{ id: 'ipleak', name: 'IPLeak', ip: '检测失败', country_code: '', org: '检测失败' },
|
||||
],
|
||||
},
|
||||
'speedtest:finished': {
|
||||
downloadSpeed: 500.2, uploadSpeed: 42.1, latency: 12, jitter: '-',
|
||||
downLoadedLatency: 45, upLoadedLatency: '-',
|
||||
scores: { streaming: 90, gaming: 70, rtc: 80 },
|
||||
qualities: { streaming: 'great', gaming: 'good', rtc: 'good' },
|
||||
connection: { ip: '104.16.0.1', colo: 'HKG', loc: 'HK', country: 'Hong Kong', coloCountry: 'Hong Kong', coloCountryCode: 'HK', coloCity: 'Hong Kong' },
|
||||
},
|
||||
'pingtest:finished': {
|
||||
target: '1.2.3.4',
|
||||
probes: [
|
||||
{ country: 'US', stats: { min: 1.2, max: 4.5, avg: 2.3, total: 8, loss: 0, rcv: 8, drop: 0, mdev: 0.4 } },
|
||||
],
|
||||
},
|
||||
'mtrtest:finished': {
|
||||
target: '8.8.8.8',
|
||||
probes: [
|
||||
{
|
||||
country: 'DE', city: 'Falkenstein', network: 'Hetzner Online', asn: 24940,
|
||||
hops: [
|
||||
{ n: 1, asn: null, host: '_gateway', ip: '172.17.0.1', lossPct: 0, drop: 0, rcv: 3, avgMs: 0.3 },
|
||||
{ n: 2, lossPct: 100 },
|
||||
],
|
||||
},
|
||||
{ country: 'US', city: 'Ashburn', network: 'AWS', asn: 14618, hops: [] },
|
||||
],
|
||||
},
|
||||
'ruletest:finished': {
|
||||
uniqueIPCount: 3, // counts error labels too — builder recomputes
|
||||
workers: [
|
||||
{ id: 1, ip: '1.2.3.4', country_code: 'US', org: 'Cloudflare' },
|
||||
{ id: 2, ip: '1.2.3.4', country_code: 'US', org: 'Cloudflare' },
|
||||
{ id: 3, ip: '检测失败', country_code: '', org: '检测失败' },
|
||||
],
|
||||
},
|
||||
'browserinfo:finished': {
|
||||
userAgent: {
|
||||
ua: 'Mozilla/5.0 …',
|
||||
browser: { name: 'Chrome', version: '138.0.0.0', major: '138' },
|
||||
engine: { name: 'Blink', version: '138.0.0.0' },
|
||||
os: { name: 'macOS', version: '15.5' },
|
||||
device: { vendor: 'Apple', model: 'Macintosh' },
|
||||
cpu: { architecture: 'arm64' },
|
||||
},
|
||||
otherInfos: {
|
||||
cookieEnabled: true, cpucores: 10,
|
||||
languages: ['zh-CN', 'en'],
|
||||
timezone: 'Asia/Hong_Kong',
|
||||
display: { width: 1512, height: 982, colorDepth: 30, pixelRatio: 2 },
|
||||
connection: { effectiveType: '4g', downlink: 10, rtt: 50, saveData: false },
|
||||
touchPoints: 0, doNotTrack: null, pdfViewer: true,
|
||||
},
|
||||
},
|
||||
'invisibility:result': {
|
||||
proxyScore: 12, vpnScore: 88, ip: '1.2.3.4',
|
||||
flags: [{ key: 'timezone', flagged: true }, { key: 'blocklist.vpn', flagged: false }],
|
||||
},
|
||||
'enhanceddnsleak:finished': {
|
||||
rawCount: 12, resolverCount: 3,
|
||||
queries: [
|
||||
{
|
||||
ip: '8.8.8.8', queryType: 'A', transport: 'udp', ecs: '1.2.3.0/24', do: true, cd: false,
|
||||
ipInfo: { country_code: 'US', country_name: 'United States', region: 'CA', city: 'MV', asn: 15169, org: 'Google' },
|
||||
ecsInfo: { country_code: 'HK' },
|
||||
},
|
||||
{
|
||||
ip: '9.9.9.9', queryType: 'A', transport: 'tcp', ecs: '', do: false, cd: false,
|
||||
ipInfo: { country_code: 'N/A', asn: null, org: 'Quad9' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('report builders produce schema-valid sections', () => {
|
||||
for (const [event, { section, build }] of Object.entries(REPORT_EVENT_BUILDERS)) {
|
||||
it(`${event} → sections.${section} passes validateReport`, () => {
|
||||
const built = build(PAYLOADS[event]);
|
||||
assert.notEqual(built, null, `builder for ${event} returned null on a realistic payload`);
|
||||
const result = validateReport(wrap(section, built));
|
||||
assert.deepEqual(result, { ok: true, errors: [] });
|
||||
});
|
||||
}
|
||||
|
||||
it('every builder returns null on an empty payload', () => {
|
||||
for (const [event, { build }] of Object.entries(REPORT_EVENT_BUILDERS)) {
|
||||
assert.equal(build({}), null, `${event} should return null on {}`);
|
||||
assert.equal(build(undefined), null, `${event} should return null on undefined`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleaning rules', () => {
|
||||
it('ipinfo drops cards whose ip slot holds a placeholder', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['ipinfo:finished'];
|
||||
const section = build(PAYLOADS['ipinfo:finished']);
|
||||
assert.equal(section.cards.length, 2);
|
||||
assert.deepEqual(section.cards.map((c) => c.ip), ['1.2.3.4', '2001:db8::1']);
|
||||
assert.equal(section.cards[0].countryCode, 'US'); // normalized to upper
|
||||
});
|
||||
|
||||
it('connectivity keeps only settled targets and maps the enum', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['connectivity:finished'];
|
||||
const section = build(PAYLOADS['connectivity:finished']);
|
||||
assert.equal(section.targets.length, 2);
|
||||
assert.equal(section.targets[0].status, 'ok');
|
||||
assert.equal(section.targets[1].status, 'unreachable');
|
||||
// custom:false is omitted, not serialized
|
||||
assert.equal('custom' in section.targets[0], false);
|
||||
});
|
||||
|
||||
it('webrtc drops waiting servers, keeps errored ones without ip/org', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['webrtc:finished'];
|
||||
const section = build(PAYLOADS['webrtc:finished']);
|
||||
assert.equal(section.servers.length, 2);
|
||||
const errored = section.servers[1];
|
||||
assert.equal(errored.natType, 'error');
|
||||
assert.equal('ip' in errored, false); // placeholder text is not an IP
|
||||
assert.equal('org' in errored, false); // no geo lookup → org distrusted
|
||||
});
|
||||
|
||||
it('speedtest turns "-" placeholders into absent keys', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['speedtest:finished'];
|
||||
const section = build(PAYLOADS['speedtest:finished']);
|
||||
assert.equal(section.jitterMs, undefined);
|
||||
assert.equal(section.loadedLatencyUpMs, undefined);
|
||||
assert.equal(section.downloadMbps, 500.2);
|
||||
assert.deepEqual(section.connection, { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK', coloCity: 'Hong Kong' });
|
||||
});
|
||||
|
||||
it('pingtest whitelists stats keys (mdev dropped)', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['pingtest:finished'];
|
||||
const section = build(PAYLOADS['pingtest:finished']);
|
||||
assert.equal('mdev' in section.probes[0].stats, false);
|
||||
assert.equal(section.probes[0].stats.avg, 2.3);
|
||||
});
|
||||
|
||||
it('mtrtest drops probes without hops', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['mtrtest:finished'];
|
||||
const section = build(PAYLOADS['mtrtest:finished']);
|
||||
assert.equal(section.probes.length, 1);
|
||||
assert.equal(section.probes[0].asn, 24940);
|
||||
});
|
||||
|
||||
it('ruletest recomputes uniqueIPCount from valid IPs only', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['ruletest:finished'];
|
||||
const section = build(PAYLOADS['ruletest:finished']);
|
||||
assert.equal(section.workers.length, 2);
|
||||
assert.equal(section.uniqueIPCount, 1); // two workers, same egress IP
|
||||
});
|
||||
|
||||
it('browserinfo picks name/version pairs and drops fingerprint-adjacent noise', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['browserinfo:finished'];
|
||||
const section = build(PAYLOADS['browserinfo:finished']);
|
||||
assert.deepEqual(section.browser, { name: 'Chrome', version: '138.0.0.0' });
|
||||
assert.equal('device' in section, false);
|
||||
assert.deepEqual(section.connection, { effectiveType: '4g', downlink: 10, rtt: 50 });
|
||||
});
|
||||
|
||||
it('enhanceddnsleak derives DNSSEC posture and normalizes geo', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['enhanceddnsleak:finished'];
|
||||
const section = build(PAYLOADS['enhanceddnsleak:finished']);
|
||||
assert.equal(section.dnssec, 'partial'); // second row lacks DO
|
||||
assert.equal(section.queries[0].asn, '15169');
|
||||
assert.equal(section.queries[1].countryCode, ''); // 'N/A' → ''
|
||||
assert.equal('asn' in section.queries[1], false); // null asn → absent
|
||||
|
||||
const allDo = build({
|
||||
rawCount: 1, resolverCount: 1,
|
||||
queries: [{ ip: '8.8.8.8', do: true, cd: false, ipInfo: {} }],
|
||||
});
|
||||
assert.equal(allDo.dnssec, 'ok');
|
||||
});
|
||||
|
||||
it('invisibility requires both scores', () => {
|
||||
const { build } = REPORT_EVENT_BUILDERS['invisibility:result'];
|
||||
assert.equal(build({ proxyScore: 10 }), null);
|
||||
const section = build(PAYLOADS['invisibility:result']);
|
||||
assert.deepEqual(section.scores, { proxy: 12, vpn: 88 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user