Files
MyIP/tests/report-export.test.js
jason5ng32andClaude Opus 5.5 88423522e3 Feat(report): add the Ad & Tracker Blocking Test to shareable reports
A blocker setup is part of the visitor's own environment, like the
browser info and proxy-rule sections, and is worth handing to whoever
helps debug it. The section stays deliberately small: request counts per
category (blocked / not blocked / no answer / total), no per-address or
per-vendor rows, so a report stays readable and the list can change
without breaking old links. Group ids are capped strings rather than an
enum for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:04:09 +08:00

222 lines
8.9 KiB
JavaScript

// Exercises the report export layer: envelope assembly (section selection,
// ordering, deep-copy isolation, masking), the AI-facing Markdown generator
// and the download filename. i18n is stubbed — the generator only needs a
// t() shape, prose content is irrelevant here.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
SECTION_TITLE_KEYS,
buildShareReport,
reportToMarkdown,
reportFileName,
} from '../frontend/utils/report/report-export.js';
import { REPORT_SECTION_IDS, REPORT_VERSION, validateReport } from '../common/report-schema.js';
// t() stub: returns the key, with params appended so interpolation is visible.
const t = (key, params) => (params ? `${key}[${Object.values(params).join(',')}]` : key);
const makeSections = () => ({
persona: {
testedAt: '2026-07-14T08:20:00.000Z',
country: 'JP',
grade: 'C',
score: 72,
counts: {
total: 18, scored: 12, match: 9, mismatch: 2,
unnatural: 0, leak: 1, unknown: 3, notApplicable: 3,
},
results: [
{ id: 'ip-country', axis: 'match', verdict: 'match' },
{ id: 'gps-location', axis: 'leak', verdict: 'leak' },
],
},
adblocktest: {
testedAt: '2026-07-14T08:25:00.000Z',
groups: [{ id: 'ads', total: 3, blocked: 2, open: 1, unknown: 0 }],
},
ipinfo: {
testedAt: '2026-07-14T08:00:00.000Z',
cards: [{ source: 'IPCheck.ing IPv4', ip: '1.2.3.4', countryCode: 'US', city: 'LA', timezone: 'America/Los_Angeles', asn: 'AS15169', isp: 'Google' }],
},
speedtest: {
testedAt: '2026-07-14T08:05:00.000Z',
downloadMbps: 500.2,
uploadMbps: 42.1,
latencyMs: 12,
scores: { streaming: 90, gaming: 70, rtc: 80 },
qualities: { streaming: 'great', gaming: 'good', rtc: 'good' },
connection: { ip: '104.16.0.1', colo: 'HKG', coloCountryCode: 'HK' },
},
mtrtest: {
testedAt: '2026-07-14T08:10:00.000Z',
target: '1.2.3.4',
probes: [{
countryCode: 'DE',
network: 'Hetzner',
asn: 24940,
hops: [
{ n: 1, host: '_gateway', ip: '172.17.0.1', lossPct: 0, avgMs: 0.3 },
{ n: 2, lossPct: 100 },
],
}],
},
});
describe('buildShareReport', () => {
it('assembles a schema-valid envelope from selected sections', () => {
const report = buildShareReport({
sections: makeSections(),
selectedIds: ['speedtest', 'ipinfo', 'mtrtest'],
maskTail: false,
locale: 'zh',
origin: 'ipcheck.ing',
});
assert.equal(report.v, REPORT_VERSION);
assert.equal(report.origin, 'ipcheck.ing');
assert.equal(report.locale, 'zh');
assert.ok(!Number.isNaN(Date.parse(report.generatedAt)));
// Homepage order, not selection order.
assert.deepEqual(Object.keys(report.sections), ['ipinfo', 'speedtest', 'mtrtest']);
assert.deepEqual(validateReport(report), { ok: true, errors: [] });
});
it('includes only selected sections and skips ids without data', () => {
const report = buildShareReport({
sections: makeSections(),
selectedIds: ['speedtest', 'webrtc'], // webrtc has no snapshot
maskTail: false,
locale: 'en',
origin: 'localhost',
});
assert.deepEqual(Object.keys(report.sections), ['speedtest']);
});
it('applies tail masking across sections when asked', () => {
const report = buildShareReport({
sections: makeSections(),
selectedIds: ['ipinfo', 'speedtest', 'mtrtest'],
maskTail: true,
locale: 'en',
origin: 'localhost',
});
assert.equal(report.sections.ipinfo.cards[0].ip, '1.2.3.x');
assert.equal(report.sections.speedtest.connection.ip, '104.16.0.x');
assert.equal(report.sections.mtrtest.target, '1.2.3.x');
assert.equal(report.sections.mtrtest.probes[0].hops[0].ip, '172.17.0.x');
assert.equal(validateReport(report).ok, true);
});
it('deep-copies snapshots so later mutations cannot alter an exported report', () => {
const sections = makeSections();
const report = buildShareReport({
sections, selectedIds: ['ipinfo'], maskTail: false, locale: 'en', origin: 'localhost',
});
sections.ipinfo.cards[0].ip = '9.9.9.9';
assert.equal(report.sections.ipinfo.cards[0].ip, '1.2.3.4');
});
});
describe('SECTION_TITLE_KEYS', () => {
it('covers every schema section id', () => {
assert.deepEqual(Object.keys(SECTION_TITLE_KEYS).sort(), [...REPORT_SECTION_IDS].sort());
});
});
describe('reportToMarkdown', () => {
const makeReport = (maskTail = false) => buildShareReport({
sections: makeSections(),
selectedIds: ['ipinfo', 'speedtest', 'mtrtest'],
maskTail,
locale: 'en',
origin: 'ipcheck.ing',
});
it('renders the persona section as verdicts, carrying no detail values', () => {
const report = buildShareReport({
sections: makeSections(),
selectedIds: ['persona'],
maskTail: false,
locale: 'en',
origin: 'ipcheck.ing',
});
const md = reportToMarkdown(report, t);
assert.ok(md.includes('| ip-country | match | match |'));
assert.ok(md.includes('| gps-location | leak | leak |'));
assert.ok(md.includes('grade: C'));
assert.ok(md.includes('score: 72/100'));
});
it('renders the adblocktest section as its per-category table', () => {
const report = buildShareReport({
sections: makeSections(),
selectedIds: ['adblocktest'],
maskTail: false,
locale: 'en',
origin: 'ipcheck.ing',
});
assert.deepEqual(validateReport(report), { ok: true, errors: [] });
const md = reportToMarkdown(report, t);
assert.ok(md.includes('| group | total | blocked | notBlocked | noAnswer |'));
assert.ok(md.includes('| ads | 3 | 2 | 1 | 0 |'));
});
it('renders heading, intro, per-section blocks and the closing instruction', () => {
const md = reportToMarkdown(makeReport(), t);
assert.ok(md.startsWith('# report.ai.Heading'));
assert.ok(md.includes('report.ai.Intro[ipcheck.ing'));
for (const id of ['ipinfo', 'speedtest', 'mtrtest']) {
assert.ok(md.includes(`## ${SECTION_TITLE_KEYS[id]}`), `missing title for ${id}`);
assert.ok(md.includes(`report.ai.Sections.${id}`), `missing explanation for ${id}`);
}
assert.ok(md.trimEnd().endsWith('report.ai.Instruction'));
// Data made it into the tables.
assert.ok(md.includes('| IPCheck.ing IPv4 | 1.2.3.4 |'));
// Every schema field the ipinfo card carries reaches the AI table —
// its columns are hand-listed, so a new field is silently dropped
// unless it is added there too.
assert.ok(md.includes('| source | ip | countryCode | region | city | timezone | asn | isp |'));
assert.ok(md.includes('America/Los_Angeles'));
assert.ok(md.includes('- downloadMbps: 500.2'));
assert.ok(md.includes('### probe: DE — Hetzner (AS24940)'));
});
it('omits sections absent from the report and skips the masked note by default', () => {
const md = reportToMarkdown(makeReport(), t);
assert.ok(!md.includes(SECTION_TITLE_KEYS.webrtc));
assert.ok(!md.includes('report.ai.Masked'));
});
it('adds the masked note when masked, and hop tables only carry present stats', () => {
const md = reportToMarkdown(makeReport(true), t, { masked: true });
assert.ok(md.includes('report.ai.Masked'));
assert.ok(md.includes('1.2.3.x'));
// The mtr fixture has lossPct/avgMs only — absent stats stay out of the header.
assert.ok(md.includes('| n | host | ip | asn | lossPct | avgMs |'));
assert.ok(!md.includes('worstMs'));
// The no-reply hop renders em-dash placeholders, keeping row width.
assert.ok(md.includes('| 2 | — | — | — | 100 | — |'));
});
it('escapes pipe characters inside values', () => {
const report = makeReport();
report.sections.ipinfo.cards[0].isp = 'Evil|Corp';
const md = reportToMarkdown(report, t);
assert.ok(md.includes('Evil\\|Corp'));
});
it('escapes backslashes so they cannot neutralize pipe escaping', () => {
const report = makeReport();
report.sections.ipinfo.cards[0].isp = 'Evil\\|Corp';
const md = reportToMarkdown(report, t);
assert.ok(md.includes('Evil\\\\\\|Corp'));
});
});
describe('reportFileName', () => {
it('derives a dated json filename from generatedAt', () => {
assert.equal(reportFileName('2026-07-14T08:00:00.000Z'), 'myip-report-2026-07-14.json');
});
});