Feat(connectivity): User-managed target set with removable presets, plus multi-round latency bars

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Fable 5 committed 2026-08-20 21:15:38 +08:00
1 parent e757c619ea
commit e2c0d8d1ca
15 files changed
+368 -248

No files matched your search

+4 -3
View File
@@ -32,9 +32,10 @@ frontend/
│ pulse-statuses (Earth Online vocabulary: presets +
│ date-windowed festival statuses + their celebration
│ effect mapping; recipes in utils/pulse-celebration.js)/
│ connectivity-import-lists (curated target sets; icons
│ are committed 64px PNGs under public/favicons/ — one
│ per member, enforced by its data test)
│ connectivity-import-lists (default target set +
│ curated import sets + the shared cap; icons are
│ committed 64px PNGs under public/favicons/ — one per
│ member, enforced by its data test)
├── utils/ ← framework-agnostic helpers + IO
│ (app-events bus / getips/ / valid-ip / analytics / …)
├── composables/ ← Vue-aware `useXxx` logic
+104 -80
View File
@@ -22,14 +22,20 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card v-for="test in connectivityTests" :key="test.id"
class="keyboard-shortcut-card group relative transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50 jn-card">
<!-- Custom-card remove button, fades in on hover -->
<button v-if="test.custom" type="button" @click.stop="removeCustomTarget(test.id)"
class="absolute top-1.5 right-1.5 p-1 rounded-md text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-opacity cursor-pointer"
<!-- Remove button, fades in on hover. Two-step confirm: first click
arms "X?" (self-reverting), the second deletes; on the last
remaining target the click just raises a toast. -->
<button type="button" @click.stop="handleRemoveClick(test.id)"
class="absolute top-1.5 right-1.5 p-1 rounded-md inline-flex items-center transition-opacity cursor-pointer"
:class="confirmingRemoveId === test.id
? 'text-destructive bg-muted'
: 'text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted'"
:aria-label="t('connectivity.addCustom.Remove')" :title="t('connectivity.addCustom.Remove')">
<X class="size-3.5" />
<span v-if="confirmingRemoveId === test.id" class="text-xs font-semibold leading-none">?</span>
</button>
<CardContent class="p-4">
<!-- Site favicon (built-in / imported) or first-letter tile (custom) + name.
<!-- Site favicon (default / imported) or first-letter tile (custom) + name.
Favicons are same-origin (public/favicons/), so they render even
when the tested site is unreachable; a load error falls back to
the letter tile. -->
@@ -61,13 +67,14 @@
{{ test.time }}<span class="ml-0.5 text-sm">ms</span>
</span>
</div>
<!-- Multi-test per-round progress dots (historical snapshot). -->
<div v-if="multipleTests" class="flex items-center gap-1 mt-2">
<!-- Multi-test per-round latency bars, all on one absolute scale
(taller = slower, see barStyle); failed / pending rounds are
short gray stubs. -->
<div v-if="multipleTests" class="flex items-end gap-1 mt-2 h-4">
<JnTooltip v-for="i in totalRounds" :key="i" :text="roundTooltipText(test, i - 1)" side="top">
<span class="group/dot inline-flex items-center justify-center p-1 -m-1 cursor-default">
<span
class="size-1.5 rounded-full md:transition-transform md:duration-200 md:group-hover/dot:scale-[2.0]"
:class="progressDotClass(test, i - 1)"></span>
<span class="group/bar inline-flex h-full items-end justify-center px-1 -mx-1 cursor-default">
<span class="w-1.5 rounded-[2px] md:transition-[height,filter] md:duration-200 md:group-hover/bar:brightness-125"
:class="barClass(test, i - 1)" :style="barStyle(test, i - 1)"></span>
</span>
</JnTooltip>
</div>
@@ -75,13 +82,12 @@
</Card>
<!-- "Add Test" tile: stacked flag/brand icons signal that curated
lists live behind it, not just a blank form. Always visible —
at the cap the dialog is still the way to remove imported lists. -->
lists live behind it, not just a blank form. -->
<Card @click="addDialogOpen = true"
class="cursor-pointer border-dashed bg-transparent hover:bg-muted/50 transition-colors"
:title="t('connectivity.addCustom.AddCard')">
<CardContent class="p-4 flex flex-col items-center justify-center gap-2 text-muted-foreground"
:class="multipleTests ? 'min-h-[106px]' : 'min-h-[92px]'">
:class="multipleTests ? 'min-h-29' : 'min-h-23'">
<span class="flex -space-x-2">
<template v-for="item in TILE_PREVIEW" :key="item.emoji || item.id">
<span v-if="item.type === 'emoji'"
@@ -99,7 +105,7 @@
</div>
<!-- Add / import dialog (custom form + curated list browser) -->
<ConnectivityAddDialog v-model:open="addDialogOpen" :builtin-urls="builtinUrls" />
<ConnectivityAddDialog v-model:open="addDialogOpen" />
</section>
</template>
@@ -111,6 +117,7 @@ import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js';
import { TILE_PREVIEW, faviconPath } from '@/data/connectivity-import-lists.js';
import { removeTarget } from '@/utils/connectivity-import.js';
import { JnTooltip } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -141,71 +148,42 @@ const totalRounds = computed(() => 1 + maxCounts.value);
const allRoundsDone = ref(false);
const alertFired = ref(false);
// Built-in targets first; custom / imported ones (`.custom = true`) are
// merged in by the watcher below. Keeping both in one reactive array lets
// every iterator (run-loop, keyboard nav) treat tiles uniformly.
// `roundResults` records per-round { tone, time } for the progress dots,
// independent of the best-of-N face/text. `time` powers the per-dot hover
// tooltip; for `tone: 'fail'` rounds it stays 0. Bootstrap-only writer.
// Icons come from the committed same-origin favicon set (public/favicons/).
const builtinTarget = (id, name, url) => ({
id, name, url, favicon: faviconPath(id), status: t('connectivity.StatusWait'), time: 0, mintime: 0, roundResults: [],
// One reactive test entry per stored target (the connectivityTargets
// preference holds the whole set, defaults included). `roundResults`
// records per-round { tone, time } for the latency bars; icons come from
// the committed favicon set, hand-added targets fall back to letter tiles.
const targetEntry = (target) => reactive({
id: target.id,
name: target.name,
url: target.url,
favicon: target.favicon || null,
status: t('connectivity.StatusWait'),
time: 0,
mintime: 0,
roundResults: [],
});
const connectivityTests = reactive([
builtinTarget('google', 'Google', 'https://www.google.com/favicon.ico'),
builtinTarget('youtube', 'YouTube', 'https://www.youtube.com/favicon.ico'),
builtinTarget('github', 'GitHub', 'https://github.com/favicon.ico'),
// Use speed.cloudflare.com (not www.cloudflare.com): the marketing site
// attaches `Link: rel=preload` headers for its brand fonts to every response
// — including /favicon.ico — which the browser honors regardless of fetch
// mode, then fails CORS on the woff2 files. speed.cloudflare.com serves a
// plain favicon with no preload headers.
builtinTarget('cloudflare', 'Cloudflare', 'https://speed.cloudflare.com/favicon.ico'),
builtinTarget('claude', 'Claude', 'https://claude.com/favicon.ico'),
builtinTarget('chatgpt', 'ChatGPT', 'https://chatgpt.com/favicon.ico'),
builtinTarget('wechat', 'WeChat', 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico'),
]);
const connectivityTests = reactive([]);
// Hostname-dedupe input for the import dialog.
const builtinUrls = connectivityTests.map((test) => test.url);
// Reconcile custom targets by id (not wipe-and-refill) so existing cards
// don't flash back to "Awaiting Test" each time the user adds another one.
// Reconcile by id (not wipe-and-refill) so existing cards don't flash back
// to "Awaiting Test" each time the user adds or removes another one.
watch(
() => userPreferences.value.customConnectivityTargets,
() => userPreferences.value.connectivityTargets,
(newTargets) => {
const targets = newTargets || [];
const targetIds = new Set(targets.map((t) => t.id));
// Drop customs no longer in storage.
// Drop targets no longer in storage.
for (let i = connectivityTests.length - 1; i >= 0; i--) {
const entry = connectivityTests[i];
if (entry.custom && !targetIds.has(entry.id)) {
if (!targetIds.has(connectivityTests[i].id)) {
connectivityTests.splice(i, 1);
}
}
// Push only newcomers.
const existingCustomIds = new Set(
connectivityTests.filter((entry) => entry.custom).map((entry) => entry.id),
);
const existingIds = new Set(connectivityTests.map((entry) => entry.id));
for (const target of targets) {
if (existingCustomIds.has(target.id)) continue;
const entry = reactive({
id: target.id,
name: target.name,
url: target.url,
custom: true,
// Imported entries carry a same-origin favicon + their source list;
// hand-added ones fall back to the letter tile.
favicon: target.favicon || null,
listId: target.listId || null,
status: t('connectivity.StatusWait'),
time: 0,
mintime: 0,
roundResults: [],
});
if (existingIds.has(target.id)) continue;
const entry = targetEntry(target);
connectivityTests.push(entry);
// Cards added after the bootstrap pass (import / hand-add) test
// themselves right away instead of sitting at "Awaiting Test".
@@ -228,13 +206,38 @@ const toneOf = (test) => {
};
const { dotClass, textClass } = useStatusTone();
// Filled → tone color; head-of-queue → pulse (suppressed once the user
// takes over manually so we don't promise rounds that won't land); rest → dim.
const progressDotClass = (test, idx) => {
// Bar color, by absolute latency bucket: green <200ms, yellow <1000ms, red
// beyond (extreme latency shares the fail tone), solid gray for
// unreachable rounds. Pending bars stay faint; head-of-queue pulses
// (suppressed once the user takes over manually).
const BAR_RED_MS = 1000;
const barClass = (test, idx) => {
const entry = test.roundResults[idx];
if (entry) return dotClass(entry.tone);
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
return 'bg-muted-foreground/20';
if (!entry) {
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
return 'bg-muted-foreground/20';
}
if (entry.tone === 'fail') return 'bg-muted-foreground';
if (entry.time < 200) return dotClass('ok-fast');
if (entry.time < BAR_RED_MS) return dotClass('ok-slow');
return dotClass('fail');
};
// Bar height, on the card's own dynamic range: the fastest round sits at
// the floor (kept hoverable), the slowest at 100%, the rest on a log curve
// between — rising latency compresses into ever-smaller height steps, and
// the top end is told apart by color instead. Failed and pending rounds
// have no latency and sit at the floor.
const BAR_FLOOR = 25;
const barStyle = (test, idx) => {
const entry = test.roundResults[idx];
if (!entry || !entry.time) return { height: `${BAR_FLOOR}%` };
const times = test.roundResults.map((r) => r.time).filter((v) => v > 0);
const min = Math.min(...times);
const max = Math.max(...times);
if (max === min) return { height: '100%' };
const p = Math.log(entry.time / min) / Math.log(max / min);
return { height: `${Math.round(BAR_FLOOR + (100 - BAR_FLOOR) * p)}%` };
};
// Per-dot hover tooltip: empty string disables the tooltip (JnTooltip's
@@ -344,7 +347,7 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => {
targets: connectivityTests.map((test) => ({
id: test.id,
name: test.name,
custom: test.custom === true,
custom: test.id.startsWith('custom-'),
statusCode: test.statusCode,
time: test.time,
mintime: test.mintime,
@@ -380,20 +383,40 @@ const updateConnectivityAlert = (type) => {
}
};
// ── Add/remove custom targets ──────────────────────────────────────────────
// ── Add/remove targets ─────────────────────────────────────────────────────
// The add/import dialog itself lives in ConnectivityAddDialog.vue; this
// component only owns the open flag and per-card removal.
const addDialogOpen = ref(false);
const removeCustomTarget = (id) => {
const current = userPreferences.value.customConnectivityTargets || [];
store.updatePreference(
'customConnectivityTargets',
current.filter((t) => t.id !== id),
);
const removeTargetById = (id) => {
const result = removeTarget(userPreferences.value.connectivityTargets || [], id);
if (result.error) return;
store.updatePreference('connectivityTargets', result.targets);
trackEvent('Section', 'RemoveCustomTarget', 'Connectivity');
};
// Two-step removal: first click arms "X?" (self-reverting), second click
// deletes. The last remaining target skips the dance — the set must never
// go empty, so the click just explains itself via toast.
const confirmingRemoveId = ref(null);
let confirmResetTimer = null;
const handleRemoveClick = (id) => {
clearTimeout(confirmResetTimer);
if ((userPreferences.value.connectivityTargets || []).length <= 1) {
confirmingRemoveId.value = null;
store.setAlert(true, 'text-warning',
t('connectivity.addCustom.LastTargetMessage'), t('connectivity.addCustom.LastTargetTitle'));
return;
}
if (confirmingRemoveId.value === id) {
confirmingRemoveId.value = null;
removeTargetById(id);
return;
}
confirmingRemoveId.value = id;
confirmResetTimer = setTimeout(() => { confirmingRemoveId.value = null; }, 3000);
};
// Multi-mode aggregate: `mintime > 0` ≡ reachable in at least one round.
const finalizeMultiTestAlert = () => {
const total = connectivityTests.length;
@@ -459,12 +482,13 @@ onMounted(() => {
store.setMountingStatus('Connectivity', true);
});
// Stop the interval on unmount.
// Stop the interval (and any pending confirm revert) on unmount.
onBeforeUnmount(() => {
if (intervalId.value !== null) {
clearInterval(intervalId.value);
intervalId.value = null;
}
clearTimeout(confirmResetTimer);
});
// Either signal flipping fires sendAlert; the gates inside pick the winner.
@@ -13,22 +13,18 @@
{{ t('connectivity.importDialog.CustomOption') }}</TabsTrigger>
</TabsList>
<!-- Curated lists: one card per list, two columns on desktop;
import / remove in place. -->
<!-- Curated lists, two columns on desktop; the system defaults
close the grid as a recovery entry. Lists may overlap —
hostname dedupe on import keeps cards unique. -->
<TabsContent value="import" class="space-y-3">
<ul class="grid gap-2 md:grid-cols-2 max-h-[55vh] overflow-y-auto pr-1">
<li v-for="list in IMPORT_LISTS" :key="list.id"
<li v-for="list in importableLists" :key="list.id"
class="flex items-start gap-3 p-3 rounded-lg border bg-card">
<span class="size-6 shrink-0 inline-flex items-center justify-center text-lg leading-none">
{{ list.emoji }}</span>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<!-- The inline check is the imported-state marker; the action
column stays a single icon either way. -->
<span class="flex items-center gap-1.5 min-w-0">
<span class="truncate" :title="t('connectivity.importLists.' + list.id)">
{{ t('connectivity.importLists.' + list.id) }}</span>
<Check v-if="importedIds.has(list.id)" class="size-3.5 shrink-0 text-success" />
</span>
<span class="truncate min-w-0" :title="t('connectivity.importLists.' + list.id)">
{{ t('connectivity.importLists.' + list.id) }}</span>
<span class="flex items-center gap-2">
<span class="flex -space-x-1.5">
<img v-for="m in list.members.slice(0, 4)" :key="m.id" :src="faviconPath(m.id)" alt=""
@@ -38,25 +34,25 @@
{{ t('connectivity.importDialog.SiteCount', { n: list.members.length }) }}</span>
</span>
</span>
<Button v-if="!importedIds.has(list.id)" size="icon" variant="outline"
<!-- Fully present → the import button itself becomes an inert
green check, so the action column never goes empty. -->
<Button v-if="!isListFullyPresent(list, targets)" size="icon" variant="outline"
class="size-8 shrink-0 cursor-pointer" @click="importList(list)"
:title="t('connectivity.importDialog.Import')" :aria-label="t('connectivity.importDialog.Import')">
<Plus class="size-4" />
</Button>
<Button v-else size="icon" variant="ghost"
class="size-8 shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
@click="removeList(list)" :title="t('connectivity.importDialog.RemoveList')"
:aria-label="t('connectivity.importDialog.RemoveList')">
<X class="size-4" />
<Button v-else size="icon" variant="ghost" disabled
class="size-8 shrink-0 text-success disabled:opacity-100">
<Check class="size-4" />
</Button>
</li>
</ul>
<!-- Success stays quiet (the inline check is the confirmation);
only a cap overflow surfaces a message here. -->
<!-- Success stays quiet (the button turning into a check is the
confirmation); only a cap overflow surfaces a message here. -->
<div class="flex flex-col items-end justify-end gap-1 min-h-4 text-xs text-muted-foreground">
<span aria-live="polite" class="text-destructive">{{ importResult || '&nbsp;' }}</span>
<span class="tabular-nums shrink-0">
{{ t('connectivity.importDialog.Capacity', { used: customTargets.length, limit: CONNECTIVITY_TARGET_LIMIT
{{ t('connectivity.importDialog.Capacity', { used: targets.length, limit: CONNECTIVITY_TARGET_LIMIT
}) }}
</span>
</div>
@@ -99,35 +95,35 @@
// curated country/theme list (default tab, matching the tile's stacked-icon
// cue), or hand-add a single custom target (migrated from
// ConnectivityTest.vue). Imports materialize list members into the
// customConnectivityTargets preference tagged with listId — imported cards
// then behave exactly like hand-added ones (individually removable); this
// dialog is also where a whole list gets removed again.
// connectivityTargets preference — imported cards then behave exactly like
// hand-added ones (individually removable in the grid).
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMainStore } from '@/store';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events';
import {
IMPORT_LISTS, CONNECTIVITY_TARGET_LIMIT, faviconPath,
IMPORT_LISTS, SYSTEM_IMPORT_LIST, CONNECTIVITY_TARGET_LIMIT, faviconPath,
} from '@/data/connectivity-import-lists.js';
import { planImport, importedListIds, withoutList } from '@/utils/connectivity-import.js';
import { planImport, isListFullyPresent } from '@/utils/connectivity-import.js';
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Check, CirclePlus, Plus, X } from '@lucide/vue';
import { Check, CirclePlus, Plus } from '@lucide/vue';
const props = defineProps({
open: { type: Boolean, default: false },
// Built-in target URLs, for hostname dedupe during import planning.
builtinUrls: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open']);
const { t } = useI18n();
const store = useMainStore();
const customTargets = computed(() => store.userPreferences.customConnectivityTargets || []);
const targets = computed(() => store.userPreferences.connectivityTargets || []);
// System defaults last — a recovery entry, not a suggestion.
const importableLists = [...IMPORT_LISTS, SYSTEM_IMPORT_LIST];
const tab = ref('import');
const importResult = ref('');
@@ -148,13 +144,8 @@ watch(tab, (v) => {
});
// ── Curated lists ──────────────────────────────────────────────────────────
const importedIds = computed(() => importedListIds(customTargets.value));
const importList = (list) => {
const plan = planImport(list, {
existingUrls: [...props.builtinUrls, ...customTargets.value.map((c) => c.url)],
currentCount: customTargets.value.length,
});
const plan = planImport(list, targets.value);
// All-or-nothing (enforced in planImport): a capacity shortfall stores
// nothing, so the list is never half-imported behind a complete-looking ✓.
if (plan.overflowCount) {
@@ -164,18 +155,12 @@ const importList = (list) => {
});
return;
}
store.updatePreference('customConnectivityTargets', [...customTargets.value, ...plan.additions]);
// Quiet on success — the inline check marks the list.
store.updatePreference('connectivityTargets', [...targets.value, ...plan.additions]);
// Quiet on success — the button flips to a check.
importResult.value = '';
trackEvent('Section', 'ImportList', list.id);
};
const removeList = (list) => {
store.updatePreference('customConnectivityTargets', withoutList(customTargets.value, list.id));
importResult.value = '';
trackEvent('Section', 'RemoveImportList', list.id);
};
// ── Custom add (migrated intact from ConnectivityTest.vue) ────────────────
const addName = ref('');
const addUrl = ref('');
@@ -227,7 +212,7 @@ const handleAdd = () => {
return;
}
if (customTargets.value.length >= CONNECTIVITY_TARGET_LIMIT) {
if (targets.value.length >= CONNECTIVITY_TARGET_LIMIT) {
addError.value = t('connectivity.addCustom.LimitReached');
return;
}
@@ -237,7 +222,7 @@ const handleAdd = () => {
name: name.slice(0, 20),
url,
};
store.updatePreference('customConnectivityTargets', [...customTargets.value, newTarget]);
store.updatePreference('connectivityTargets', [...targets.value, newTarget]);
trackEvent('Section', 'AddCustomTarget', 'Connectivity');
// Hand-add only — importList() deliberately doesn't emit this.
emitAppEvent('connectivity:custom-added', { name: newTarget.name, url: newTarget.url });
+13 -4
View File
@@ -1611,10 +1611,19 @@
{
"type": "add",
"change": {
"en": "Connectivity: new importable list — global e-commerce platforms (Amazon, AliExpress, Temu, SHEIN and more)",
"zh": "网络连通性新增可导入列表:国际电商平台(Amazon、AliExpress、Temu、SHEIN 等)",
"fr": "Connectivité : nouvelle liste importable — plateformes e-commerce internationales (Amazon, AliExpress, Temu, SHEIN et plus)",
"ru": "Проверка доступности: новый список для импорта — международные площадки электронной коммерции (Amazon, AliExpress, Temu, SHEIN и другие)"
"en": "Connectivity: preset sites are now removable, plus a new Global E-commerce import list",
"zh": "网络连通性测试可删除预设网站、新增国际电商平台导入列表",
"fr": "Connectivité : les sites prédéfinis sont désormais supprimables, avec une nouvelle liste importable « E-commerce mondial »",
"ru": "Проверка доступности: предустановленные сайты теперь можно удалять, добавлен импортируемый список «Электронная коммерция»"
}
},
{
"type": "improve",
"change": {
"en": "Connectivity: multi-round test results now render as beautiful latency bars",
"zh": "使用精美的柱状图展示多次刷新模式下的网络连通性测试结果",
"fr": "Connectivité : les résultats des tests multi-passes s'affichent désormais en élégantes barres de latence",
"ru": "Проверка доступности: результаты многократных тестов теперь отображаются изящными столбиками задержки"
}
},
{
+47 -22
View File
@@ -1,35 +1,54 @@
// Curated importable target lists for the Connectivity section: country sets
// (domestic reachability) and theme sets (global services). Importing
// materializes members into the user's customConnectivityTargets preference
// (tagged with listId), so imported cards behave exactly like hand-added
// ones. Display names live in the locale packs under
// `connectivity.importLists.<id>`; member names are brand names and stay
// untranslated. Every member ships a committed 64px PNG at
// public/favicons/<id>.png (the data test enforces it) — same-origin, so
// icons render even when the tested site is blocked for the visitor.
// materializes members into the user's connectivityTargets preference, so
// imported cards behave exactly like hand-added ones. Display names live in
// the locale packs under `connectivity.importLists.<id>`; member names are
// brand names and stay untranslated. Every member ships a committed 64px
// PNG at public/favicons/<id>.png (the data test enforces it) —
// same-origin, so icons render even when the tested site is blocked for
// the visitor.
// Total cap shared by hand-added and imported targets. The grid and the
// multi-round test loop stay usable at this scale; the import dialog
// surfaces remaining headroom.
// Total cap on stored targets, defaults included; enforced at add/import
// time only, so an over-cap migrated set keeps working but can't grow.
export const CONNECTIVITY_TARGET_LIMIT = 60;
// Same-origin favicon path for a member id (fetched at dev time).
export const faviconPath = (id) => `/favicons/${id}.png`;
// The seven built-in targets render through the same favicon pipeline as
// imported members (ConnectivityTest.vue maps these ids onto its tiles).
// `iconDomain` feeds scripts/fetch-favicons.js — it differs from the test
// URL's host where that host serves a poor or missing icon.
export const BUILTIN_FAVICONS = [
{ id: 'google', iconDomain: 'www.google.com' },
{ id: 'youtube', iconDomain: 'www.youtube.com' },
{ id: 'github', iconDomain: 'github.com' },
{ id: 'cloudflare', iconDomain: 'www.cloudflare.com' },
{ id: 'claude', iconDomain: 'claude.com' },
{ id: 'chatgpt', iconDomain: 'chatgpt.com' },
{ id: 'wechat', iconDomain: 'weixin.qq.com' },
// Targets every fresh install starts with (migration appends legacy
// customs); all removable once stored. Cloudflare tests
// speed.cloudflare.com, not www: the marketing site attaches font-preload
// headers to every response, and those preloads then fail CORS.
export const DEFAULT_LIST_MEMBERS = [
{ id: 'google', name: 'Google', url: 'https://www.google.com/favicon.ico' },
{ id: 'youtube', name: 'YouTube', url: 'https://www.youtube.com/favicon.ico' },
{ id: 'github', name: 'GitHub', url: 'https://github.com/favicon.ico' },
{ id: 'cloudflare', name: 'Cloudflare', url: 'https://speed.cloudflare.com/favicon.ico', iconDomain: 'www.cloudflare.com' },
{ id: 'claude', name: 'Claude', url: 'https://claude.com/favicon.ico' },
{ id: 'chatgpt', name: 'ChatGPT', url: 'https://chatgpt.com/favicon.ico' },
{ id: 'wechat', name: 'WeChat', url: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico', iconDomain: 'weixin.qq.com' },
];
// Favicon-fetch targets for scripts/fetch-favicons.js. `iconDomain` differs
// from the test URL's host where that host serves a poor or missing icon.
export const BUILTIN_FAVICONS = DEFAULT_LIST_MEMBERS.map((m) => ({
id: m.id,
iconDomain: m.iconDomain || new URL(m.url).hostname,
}));
// The defaults as an importable list (shown last in the dialog) to bring
// deleted default sites back. Not in IMPORT_LISTS: below its size floor.
export const SYSTEM_IMPORT_LIST = {
id: 'defaults',
emoji: '🚦',
members: DEFAULT_LIST_MEMBERS,
};
// Defaults that also belong in a themed list are referenced, not
// re-declared — themed lists stay complete on their own (AI ⊃ ChatGPT);
// hostname dedupe on import keeps cards unique.
const builtinMember = (id) => DEFAULT_LIST_MEMBERS.find((m) => m.id === id);
// Every list is fronted by an emoji (flag emoji for country lists) — one
// uniform icon system, no icon-font dependency in this data.
// Member shape: { id, name, url } (+ optional `iconDomain` when the test
@@ -72,6 +91,7 @@ export const IMPORT_LISTS = [
id: 'china',
emoji: '🇨🇳',
members: [
builtinMember('wechat'),
{ id: 'baidu', name: 'Baidu', url: 'https://www.baidu.com/favicon.ico' },
{ id: 'taobao', name: 'Taobao', url: 'https://www.taobao.com/favicon.ico' },
{ id: 'bilibili', name: 'Bilibili', url: 'https://www.bilibili.com/favicon.ico' },
@@ -89,6 +109,8 @@ export const IMPORT_LISTS = [
id: 'ai',
emoji: '🤖',
members: [
builtinMember('claude'),
builtinMember('chatgpt'),
{ id: 'gemini', name: 'Gemini', url: 'https://gemini.google.com/favicon.ico' },
{ id: 'deepseek', name: 'DeepSeek', url: 'https://chat.deepseek.com/favicon.ico', iconDomain: 'www.deepseek.com' },
{ id: 'huggingface', name: 'Hugging Face', url: 'https://huggingface.co/favicon.ico' },
@@ -125,6 +147,7 @@ export const IMPORT_LISTS = [
id: 'streaming',
emoji: '🎬',
members: [
builtinMember('youtube'),
{ id: 'netflix', name: 'Netflix', url: 'https://www.netflix.com/favicon.ico' },
{ id: 'spotify', name: 'Spotify', url: 'https://open.spotify.com/favicon.ico' },
{ id: 'tiktok', name: 'TikTok', url: 'https://www.tiktok.com/favicon.ico' },
@@ -160,6 +183,7 @@ export const IMPORT_LISTS = [
id: 'developer',
emoji: '👨‍💻',
members: [
builtinMember('github'),
{ id: 'npm', name: 'npm', url: 'https://registry.npmjs.org/favicon.ico', iconDomain: 'www.npmjs.com' },
{ id: 'dockerhub', name: 'Docker Hub', url: 'https://hub.docker.com/favicon.ico', iconDomain: 'www.docker.com' },
{ id: 'pypi', name: 'PyPI', url: 'https://pypi.org/favicon.ico' },
@@ -178,6 +202,7 @@ export const IMPORT_LISTS = [
id: 'cloud',
emoji: '☁️',
members: [
builtinMember('cloudflare'),
{ id: 'aws', name: 'AWS', url: 'https://aws.amazon.com/favicon.ico' },
{ id: 'azure', name: 'Azure', url: 'https://azure.microsoft.com/favicon.ico' },
{ id: 'gcloud', name: 'Google Cloud', url: 'https://cloud.google.com/favicon.ico' },
+5 -3
View File
@@ -27,9 +27,11 @@ export const DEFAULT_PREFERENCES = Object.freeze({
ipHistoryEnabled: true,
ipHistoryDays: 90,
lang: 'auto', // auto | zh | en | fr | tr
// User-defined extra targets for the Connectivity test grid. Each entry:
// { id: 'custom-<timestamp>', name: string, url: string-with-trailing-? }
// See ConnectivityTest.vue for how these are merged with the built-in list.
// The full Connectivity target set (defaults included). null until first
// load — store.loadPreferences() builds it via buildInitialTargets().
connectivityTargets: null,
// Legacy pre-connectivityTargets key. Read once by the build above, kept
// for rollback; never written.
customConnectivityTargets: [],
});
+4 -2
View File
@@ -1216,7 +1216,9 @@
"NameRequired": "Please enter a name.",
"UrlRequired": "Please enter a URL.",
"InvalidUrl": "That doesn't look like a valid URL.",
"LimitReached": "You've reached the 60 tests limit."
"LimitReached": "You've reached the 60 tests limit.",
"LastTargetTitle": "Can't Remove",
"LastTargetMessage": "At least one test site must remain."
},
"importDialog": {
"Title": "Add / Import Tests",
@@ -1224,11 +1226,11 @@
"ImportOption": "Import a List",
"SiteCount": "{n} sites",
"Import": "Import",
"RemoveList": "Remove this list",
"Capacity": "{used}/{limit} used",
"NotEnoughCapacity": "Not enough capacity — this list needs {need} slots, only {free} left."
},
"importLists": {
"defaults": "System Defaults",
"iran": "Iran Essentials",
"russia": "Russia Essentials",
"china": "China Essentials",
+4 -2
View File
@@ -1216,7 +1216,9 @@
"NameRequired": "Veuillez saisir un nom.",
"UrlRequired": "Veuillez saisir une URL.",
"InvalidUrl": "Cela ne ressemble pas à une URL valide.",
"LimitReached": "Limite de 60 tests atteinte."
"LimitReached": "Limite de 60 tests atteinte.",
"LastTargetTitle": "Suppression impossible",
"LastTargetMessage": "Au moins un site de test doit rester."
},
"importDialog": {
"Title": "Ajouter / importer des tests",
@@ -1224,11 +1226,11 @@
"ImportOption": "Importer une liste",
"SiteCount": "{n} sites",
"Import": "Importer",
"RemoveList": "Retirer cette liste",
"Capacity": "{used}/{limit} utilisés",
"NotEnoughCapacity": "Capacité insuffisante — cette liste nécessite {need} emplacements, seulement {free} restants."
},
"importLists": {
"defaults": "Sites par défaut",
"iran": "Essentiels Iran",
"russia": "Essentiels Russie",
"china": "Essentiels Chine",
+4 -2
View File
@@ -1216,7 +1216,9 @@
"NameRequired": "Введите название.",
"UrlRequired": "Введите URL.",
"InvalidUrl": "Похоже, это недействительный URL.",
"LimitReached": "Достигнут лимит в 60 тестов."
"LimitReached": "Достигнут лимит в 60 тестов.",
"LastTargetTitle": "Нельзя удалить",
"LastTargetMessage": "Должен остаться хотя бы один сайт для теста."
},
"importDialog": {
"Title": "Добавить / импортировать тесты",
@@ -1224,11 +1226,11 @@
"ImportOption": "Импорт списка",
"SiteCount": "Сайтов: {n}",
"Import": "Импорт",
"RemoveList": "Удалить этот список",
"Capacity": "Использовано {used}/{limit}",
"NotEnoughCapacity": "Недостаточно места — для списка нужно слотов: {need}, свободно: {free}."
},
"importLists": {
"defaults": "Системный набор",
"iran": "Иран: основные",
"russia": "Россия: основные",
"china": "Китай: основные",
+4 -2
View File
@@ -1216,7 +1216,9 @@
"NameRequired": "请输入名称",
"UrlRequired": "请输入 URL",
"InvalidUrl": "这看起来不是一个有效的 URL",
"LimitReached": "测试数量已达到 60 个上限"
"LimitReached": "测试数量已达到 60 个上限",
"LastTargetTitle": "无法删除",
"LastTargetMessage": "至少需要保留一个测试网站。"
},
"importDialog": {
"Title": "添加 / 导入测试",
@@ -1224,11 +1226,11 @@
"ImportOption": "导入列表",
"SiteCount": "{n} 个网站",
"Import": "导入",
"RemoveList": "移除该列表",
"Capacity": "已用 {used}/{limit}",
"NotEnoughCapacity": "容量不足——该列表需要 {need} 个空位,仅剩 {free} 个"
},
"importLists": {
"defaults": "系统预设",
"iran": "伊朗常用",
"russia": "俄罗斯常用",
"china": "中国常用",
+8 -1
View File
@@ -6,6 +6,7 @@ import i18n from './locales/i18n.js';
import { createInitialAchievementsState } from './data/achievements.js';
import { createInitialIpDBs, buildDbUrl, applyConfigAvailability, nearestEnabledId } from './data/ip-databases.js';
import { createDefaultPreferences, PREFS_STORAGE_KEY } from './data/default-preferences.js';
import { buildInitialTargets } from './utils/connectivity-import.js';
import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './data/sections.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
const { t } = i18n.global;
@@ -211,7 +212,13 @@ export const useMainStore = defineStore('main', {
const defaultPreferences = createDefaultPreferences();
const storedPreferences = localStorage.getItem(PREFS_STORAGE_KEY);
const currentPreferences = storedPreferences ? JSON.parse(storedPreferences) : {};
this.setPreferences({ ...defaultPreferences, ...currentPreferences });
const merged = { ...defaultPreferences, ...currentPreferences };
// One-time build of the Connectivity target set: defaults + legacy
// flat customs (the legacy key stays for rollback, never written).
if (!merged.connectivityTargets) {
merged.connectivityTargets = buildInitialTargets(merged.customConnectivityTargets);
}
this.setPreferences(merged);
},
// fetch configs from server
fetchConfigs() {
+41 -29
View File
@@ -1,8 +1,8 @@
// Pure logic for the Connectivity "import a curated list" flow: planning an
// import (dedupe against existing targets + shared cap) and removing a
// previously imported list.
// Pure logic for the connectivityTargets preference: one-time build,
// import planning (hostname dedupe + all-or-nothing cap), the dialog's
// "fully present" check, and removal. Immutable — callers persist results.
import { CONNECTIVITY_TARGET_LIMIT, faviconPath } from '../data/connectivity-import-lists.js';
import { CONNECTIVITY_TARGET_LIMIT, DEFAULT_LIST_MEMBERS, faviconPath } from '../data/connectivity-import-lists.js';
const hostnameOf = (url) => {
try {
@@ -12,40 +12,52 @@ const hostnameOf = (url) => {
}
};
// Decide what an import would actually add. `existingUrls` covers built-in
// targets plus every current custom/imported one; `currentCount` is the
// stored customConnectivityTargets length (built-ins don't count against
// the cap). Returns preference-shaped entries ready to persist.
//
// All-or-nothing: a partial import would still mark the whole list as
// imported (any stored member carries listId), hiding the import action with
// no path to add the omitted members later — so when the fresh members don't
// all fit, nothing is added and `overflowCount` reports the shortfall.
// `freshCount` / `capacity` ride along for the caller's error message.
export const planImport = (list, { existingUrls = [], currentCount = 0, limit = CONNECTIVITY_TARGET_LIMIT } = {}) => {
const existingHosts = new Set(existingUrls.map(hostnameOf).filter(Boolean));
// Stored target shape: { id, name, url, favicon? }.
const toStoredTarget = (m) => ({
id: m.id,
name: m.name,
url: m.url,
...(m.favicon ? { favicon: m.favicon } : {}),
});
// Defaults plus the legacy customConnectivityTargets entries (listId tags
// dropped). No cap here — migration must never drop sites; an over-cap
// set just can't grow.
export const buildInitialTargets = (legacyTargets = []) => [
...DEFAULT_LIST_MEMBERS.map((m) => toStoredTarget({ ...m, favicon: faviconPath(m.id) })),
...legacyTargets.map(toStoredTarget),
];
// What importing a curated list would add. Dedupe is by hostname, so lists
// may overlap each other and the defaults. All-or-nothing: if the fresh
// members don't all fit under the cap, nothing is added and overflowCount
// reports the shortfall — a partial import would sit behind a full ✓.
export const planImport = (list, targets = [], { limit = CONNECTIVITY_TARGET_LIMIT } = {}) => {
const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean));
const fresh = list.members.filter((m) => !existingHosts.has(hostnameOf(m.url)));
const skippedCount = list.members.length - fresh.length;
const capacity = Math.max(0, limit - currentCount);
const capacity = Math.max(0, limit - targets.length);
if (fresh.length > capacity) {
return { additions: [], skippedCount, overflowCount: fresh.length - capacity, freshCount: fresh.length, capacity };
}
const additions = fresh.map((m) => ({
const additions = fresh.map((m) => toStoredTarget({
...m,
id: `import-${m.id}`,
name: m.name,
url: m.url,
listId: list.id,
favicon: faviconPath(m.id),
}));
return { additions, skippedCount, overflowCount: 0, freshCount: fresh.length, capacity };
};
// Ids of lists that currently have at least one imported member standing.
export const importedListIds = (customTargets = []) => new Set(
customTargets.map((t) => t.listId).filter(Boolean),
);
// True when every member of the curated list is already present (by
// hostname) — the dialog shows a check instead of the import button then.
export const isListFullyPresent = (list, targets = []) => {
const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean));
return list.members.every((m) => existingHosts.has(hostnameOf(m.url)));
};
// Drop every remaining member of one imported list, keep everything else.
export const withoutList = (customTargets = [], listId) => customTargets.filter(
(t) => t.listId !== listId,
);
// Remove one target; the set always keeps at least one site (the UI
// toasts on the last one — this guard holds for any caller).
export const removeTarget = (targets = [], id) => {
if (targets.length <= 1) return { error: 'last-target' };
return { targets: targets.filter((t) => t.id !== id) };
};
+37 -3
View File
@@ -11,6 +11,8 @@ import { fileURLToPath } from 'node:url';
import {
IMPORT_LISTS,
BUILTIN_FAVICONS,
DEFAULT_LIST_MEMBERS,
SYSTEM_IMPORT_LIST,
TILE_PREVIEW,
faviconPath,
CONNECTIVITY_TARGET_LIMIT,
@@ -41,9 +43,41 @@ describe('import lists data integrity', () => {
assert.equal(new Set(ids).size, ids.length);
});
it('member hostnames are unique across all lists (import dedupe assumes it)', () => {
const hosts = allMembers.map((m) => new URL(m.url).hostname);
assert.equal(new Set(hosts).size, hosts.length);
it('member hostnames are unique within each list (import dedupe is by hostname)', () => {
// Cross-list overlap is deliberate — themed lists stay semantically
// complete even where they intersect the defaults (AI ⊃ ChatGPT).
for (const list of IMPORT_LISTS) {
const hosts = list.members.map((m) => new URL(m.url).hostname);
assert.equal(new Set(hosts).size, hosts.length, list.id);
}
});
it('members shared with the defaults reuse the exact default definition', () => {
// Overlapping entries are references into DEFAULT_LIST_MEMBERS, so
// url / iconDomain can never drift between the two.
const defaults = new Map(DEFAULT_LIST_MEMBERS.map((m) => [m.id, m]));
let shared = 0;
for (const m of allMembers) {
if (!defaults.has(m.id)) continue;
assert.equal(m, defaults.get(m.id), m.id);
shared += 1;
}
assert.ok(shared > 0, 'expected some default members inside themed lists');
});
it('the system import list re-exposes the defaults under a non-clashing id', () => {
assert.equal(SYSTEM_IMPORT_LIST.members, DEFAULT_LIST_MEMBERS);
assert.ok(SYSTEM_IMPORT_LIST.emoji.length > 0);
assert.ok(!IMPORT_LISTS.some((l) => l.id === SYSTEM_IMPORT_LIST.id));
});
it('default list members are well-formed and favicon-backed', () => {
const ids = DEFAULT_LIST_MEMBERS.map((m) => m.id);
assert.equal(new Set(ids).size, ids.length);
for (const m of DEFAULT_LIST_MEMBERS) {
assert.equal(new URL(m.url).protocol, 'https:', m.id);
assert.ok(existsSync(faviconFile(m.id)), `missing public/favicons/${m.id}.png`);
}
});
it('every member URL is HTTPS and parseable', () => {
+63 -51
View File
@@ -1,15 +1,20 @@
// Tests for the Connectivity import-list logic
// (frontend/utils/connectivity-import.js): import planning (dedupe + cap),
// list removal, and imported-list detection.
// Tests for frontend/utils/connectivity-import.js: initial build, import
// planning, the "fully present" check, and removal with its guard.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
buildInitialTargets,
planImport,
importedListIds,
withoutList,
isListFullyPresent,
removeTarget,
} from '../frontend/utils/connectivity-import.js';
import { CONNECTIVITY_TARGET_LIMIT } from '../frontend/data/connectivity-import-lists.js';
import {
CONNECTIVITY_TARGET_LIMIT,
DEFAULT_LIST_MEMBERS,
} from '../frontend/data/connectivity-import-lists.js';
const target = (id, host = `${id}.example`) => ({ id, name: id, url: `https://${host}/favicon.ico` });
const sampleList = {
id: 'sample',
@@ -20,9 +25,33 @@ const sampleList = {
],
};
describe('buildInitialTargets', () => {
it('seeds the defaults plus legacy targets, dropping provenance tags', () => {
const legacy = [
{ id: 'custom-1', name: 'Probe', url: 'https://probe.example/health' },
{ id: 'import-ozon', name: 'Ozon', url: 'https://www.ozon.ru/favicon.ico', listId: 'russia', favicon: '/favicons/ozon.png' },
];
const targets = buildInitialTargets(legacy);
assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + 2);
// Defaults carry their committed favicon.
const github = targets.find((m) => m.id === 'github');
assert.equal(github.favicon, '/favicons/github.png');
// Legacy entries keep their favicon but lose the listId tag.
const ozon = targets.find((m) => m.id === 'import-ozon');
assert.equal(ozon.favicon, '/favicons/ozon.png');
assert.equal('listId' in ozon, false);
});
it('never drops legacy sites, even past the cap', () => {
const legacy = Array.from({ length: CONNECTIVITY_TARGET_LIMIT }, (_, i) => target(`custom-${i}`));
const targets = buildInitialTargets(legacy);
assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + CONNECTIVITY_TARGET_LIMIT);
});
});
describe('planImport', () => {
it('materializes members as preference entries tagged with the list id', () => {
const { additions, skippedCount, overflowCount } = planImport(sampleList, {});
it('materializes members with import ids and favicons', () => {
const { additions, skippedCount, overflowCount } = planImport(sampleList, []);
assert.equal(additions.length, 3);
assert.equal(skippedCount, 0);
assert.equal(overflowCount, 0);
@@ -30,26 +59,22 @@ describe('planImport', () => {
id: 'import-alpha',
name: 'Alpha',
url: 'https://alpha.example/favicon.ico',
listId: 'sample',
favicon: '/favicons/alpha.png',
});
});
it('skips members whose hostname already exists (built-in or custom)', () => {
const { additions, skippedCount } = planImport(sampleList, {
existingUrls: [
'https://www.beta.example/some/other/path.ico',
'https://unrelated.example/favicon.ico',
],
});
it('skips members whose hostname is already present', () => {
const { additions, skippedCount } = planImport(sampleList, [
target('x', 'www.beta.example'),
target('y', 'unrelated.example'),
]);
assert.deepEqual(additions.map((a) => a.id), ['import-alpha', 'import-gamma']);
assert.equal(skippedCount, 1);
});
it('refuses a partial import when the list does not fully fit (all-or-nothing)', () => {
const { additions, overflowCount, freshCount, capacity } = planImport(sampleList, {
currentCount: CONNECTIVITY_TARGET_LIMIT - 1,
});
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 1 }, (_, i) => target(`m${i}`));
const { additions, overflowCount, freshCount, capacity } = planImport(sampleList, existing);
assert.equal(additions.length, 0);
assert.equal(overflowCount, 2);
assert.equal(freshCount, 3);
@@ -57,52 +82,39 @@ describe('planImport', () => {
});
it('imports in full when the list exactly fits the remaining capacity', () => {
const { additions, overflowCount } = planImport(sampleList, {
currentCount: CONNECTIVITY_TARGET_LIMIT - 3,
});
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 3 }, (_, i) => target(`m${i}`));
const { additions, overflowCount } = planImport(sampleList, existing);
assert.equal(additions.length, 3);
assert.equal(overflowCount, 0);
});
it('adds nothing when the cap is already reached', () => {
const { additions, overflowCount } = planImport(sampleList, {
currentCount: CONNECTIVITY_TARGET_LIMIT,
});
it('an over-cap set (legacy migration) can only shed, never gain', () => {
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT + 5 }, (_, i) => target(`m${i}`));
const { additions, overflowCount, capacity } = planImport(sampleList, existing);
assert.equal(additions.length, 0);
assert.equal(capacity, 0);
assert.equal(overflowCount, 3);
});
});
it('counts only fresh members against capacity — deduped ones do not block', () => {
// 1 slot left, 2 of 3 members already present: the single fresh one fits.
const { additions, overflowCount, skippedCount } = planImport(sampleList, {
existingUrls: ['https://alpha.example/x', 'https://www.beta.example/x'],
currentCount: CONNECTIVITY_TARGET_LIMIT - 1,
});
assert.deepEqual(additions.map((a) => a.id), ['import-gamma']);
assert.equal(overflowCount, 0);
assert.equal(skippedCount, 2);
describe('isListFullyPresent', () => {
it('is true when every member hostname exists in the target set', () => {
const targets = [target('x', 'alpha.example'), target('y', 'www.beta.example'), target('z', 'gamma.example')];
assert.equal(isListFullyPresent(sampleList, targets), true);
});
it('ignores malformed existing URLs instead of throwing', () => {
const { additions } = planImport(sampleList, { existingUrls: ['not a url'] });
assert.equal(additions.length, 3);
it('is false while any member is missing', () => {
assert.equal(isListFullyPresent(sampleList, [target('x', 'alpha.example')]), false);
});
});
describe('importedListIds / withoutList', () => {
const prefs = [
{ id: 'custom-1', name: 'Mine', url: 'https://mine.example/favicon.ico' },
{ id: 'import-alpha', listId: 'sample', url: 'https://alpha.example/favicon.ico' },
{ id: 'import-yandex', listId: 'russia', url: 'https://yandex.ru/favicon.ico' },
];
it('collects listIds from imported entries only', () => {
assert.deepEqual([...importedListIds(prefs)].sort(), ['russia', 'sample']);
describe('removeTarget', () => {
it('removes the matching target', () => {
const { targets } = removeTarget([target('a'), target('b')], 'a');
assert.deepEqual(targets.map((m) => m.id), ['b']);
});
it('removes every member of one list, keeps customs and other lists', () => {
const left = withoutList(prefs, 'sample');
assert.deepEqual(left.map((t) => t.id), ['custom-1', 'import-yandex']);
it('refuses to remove the last remaining target', () => {
assert.equal(removeTarget([target('a')], 'a').error, 'last-target');
});
});
+1
View File
@@ -25,6 +25,7 @@ describe('DEFAULT_PREFERENCES', () => {
ipHistoryEnabled: true,
ipHistoryDays: 90,
lang: 'auto',
connectivityTargets: null,
customConnectivityTargets: [],
});
});