diff --git a/frontend/App.vue b/frontend/App.vue
index a53a4d6a..852c2a75 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -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();
diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue
index 428ad22b..b2649abe 100644
--- a/frontend/components/ConnectivityTest.vue
+++ b/frontend/components/ConnectivityTest.vue
@@ -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();
});
diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue
index fa5dad74..99879e92 100644
--- a/frontend/components/DnsLeaksTest.vue
+++ b/frontend/components/DnsLeaksTest.vue
@@ -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,
+ })),
+ });
});
};
diff --git a/frontend/components/IpInfos.vue b/frontend/components/IpInfos.vue
index 41cefd6d..505d924a 100644
--- a/frontend/components/IpInfos.vue
+++ b/frontend/components/IpInfos.vue
@@ -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,
+ })),
+ });
}
};
diff --git a/frontend/components/SpeedTest.vue b/frontend/components/SpeedTest.vue
index 1117c9a4..2d2ae955 100644
--- a/frontend/components/SpeedTest.vue
+++ b/frontend/components/SpeedTest.vue
@@ -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) => {
diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue
index fccc4a71..e8da1dc6 100644
--- a/frontend/components/WebRtcTest.vue
+++ b/frontend/components/WebRtcTest.vue
@@ -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();
});
};
diff --git a/frontend/components/advanced-tools/BrowserInfo.vue b/frontend/components/advanced-tools/BrowserInfo.vue
index c980af11..cbcac2e3 100644
--- a/frontend/components/advanced-tools/BrowserInfo.vue
+++ b/frontend/components/advanced-tools/BrowserInfo.vue
@@ -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';
diff --git a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue
index 8dbdb6ff..74edb24f 100644
--- a/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue
+++ b/frontend/components/advanced-tools/EnhancedDnsLeakTest.vue
@@ -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')) {
diff --git a/frontend/components/advanced-tools/GlobalLatencyTest.vue b/frontend/components/advanced-tools/GlobalLatencyTest.vue
index ddd3c1c3..2fbc9627 100644
--- a/frontend/components/advanced-tools/GlobalLatencyTest.vue
+++ b/frontend/components/advanced-tools/GlobalLatencyTest.vue
@@ -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,
+ })),
+ });
+ },
});
};
diff --git a/frontend/components/advanced-tools/InvisibilityTest.vue b/frontend/components/advanced-tools/InvisibilityTest.vue
index adcd35cd..f6a8df65 100644
--- a/frontend/components/advanced-tools/InvisibilityTest.vue
+++ b/frontend/components/advanced-tools/InvisibilityTest.vue
@@ -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) {
diff --git a/frontend/components/advanced-tools/MtrTest.vue b/frontend/components/advanced-tools/MtrTest.vue
index ffea19cd..b34daafe 100644
--- a/frontend/components/advanced-tools/MtrTest.vue
+++ b/frontend/components/advanced-tools/MtrTest.vue
@@ -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,
+ })),
+ });
+ },
});
};
diff --git a/frontend/components/advanced-tools/RuleTest.vue b/frontend/components/advanced-tools/RuleTest.vue
index 8e65fde0..d9669bbb 100644
--- a/frontend/components/advanced-tools/RuleTest.vue
+++ b/frontend/components/advanced-tools/RuleTest.vue
@@ -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,
+ })),
});
}
}
diff --git a/frontend/composables/use-report-collector.js b/frontend/composables/use-report-collector.js
new file mode 100644
index 00000000..e25c3460
--- /dev/null
+++ b/frontend/composables/use-report-collector.js
@@ -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];
+};
diff --git a/frontend/utils/app-events.js b/frontend/utils/app-events.js
index 2bea7375..3045e982 100644
--- a/frontend/utils/app-events.js
+++ b/frontend/utils/app-events.js
@@ -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
diff --git a/frontend/utils/report-builders.js b/frontend/utils/report-builders.js
new file mode 100644
index 00000000..3856e7ab
--- /dev/null
+++ b/frontend/utils/report-builders.js
@@ -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 },
+};
diff --git a/tests/composable-report-collector.test.js b/tests/composable-report-collector.test.js
new file mode 100644
index 00000000..dc20a5ae
--- /dev/null
+++ b/tests/composable-report-collector.test.js
@@ -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();
+ });
+});
diff --git a/tests/report-builders.test.js b/tests/report-builders.test.js
new file mode 100644
index 00000000..8f7b3737
--- /dev/null
+++ b/tests/report-builders.test.js
@@ -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 });
+ });
+});