mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-01 01:10:21 +08:00
feat: re-implement plugin pinning functionality for extensions (#7918)
* feat: re-implement plugin pinning functionality for extensions Co-authored-by: Copilot <copilot@github.com> * chore: update subset --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import copy
|
||||
import traceback
|
||||
from sys import maxsize
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.message_components import Image, Plain
|
||||
from astrbot.api.provider import LLMResponse, ProviderRequest
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
FILTERS,
|
||||
USER_SESSIONS,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
from .long_term_memory import LongTermMemory
|
||||
|
||||
@@ -18,6 +28,103 @@ class Main(star.Star):
|
||||
except BaseException as e:
|
||||
logger.error(f"聊天增强 err: {e}")
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
||||
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
|
||||
"""会话控制代理"""
|
||||
for session_filter in FILTERS:
|
||||
session_id = session_filter.filter(event)
|
||||
if session_id in USER_SESSIONS:
|
||||
await SessionWaiter.trigger(session_id, event)
|
||||
event.stop_event()
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
|
||||
async def handle_empty_mention(self, event: AstrMessageEvent):
|
||||
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
|
||||
try:
|
||||
messages = event.get_messages()
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
p_settings = cfg["platform_settings"]
|
||||
wake_prefix = cfg.get("wake_prefix", [])
|
||||
if len(messages) != 1:
|
||||
return
|
||||
|
||||
is_empty_mention = (
|
||||
isinstance(messages[0], Comp.At)
|
||||
and str(messages[0].qq) == str(event.get_self_id())
|
||||
and p_settings.get("empty_mention_waiting", True)
|
||||
)
|
||||
is_wake_prefix_only = (
|
||||
isinstance(messages[0], Comp.Plain)
|
||||
and messages[0].text.strip() in wake_prefix
|
||||
)
|
||||
|
||||
if not (is_empty_mention or is_wake_prefix_only):
|
||||
return
|
||||
|
||||
if p_settings.get("empty_mention_waiting_need_reply", True):
|
||||
try:
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
conversation = None
|
||||
|
||||
if curr_cid:
|
||||
conversation = (
|
||||
await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin,
|
||||
curr_cid,
|
||||
)
|
||||
)
|
||||
else:
|
||||
curr_cid = (
|
||||
await self.context.conversation_manager.new_conversation(
|
||||
event.unified_msg_origin,
|
||||
platform_id=event.get_platform_id(),
|
||||
)
|
||||
)
|
||||
|
||||
yield event.request_llm(
|
||||
prompt=(
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
),
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {e!s}")
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
) -> None:
|
||||
if not event.message_str or not event.message_str.strip():
|
||||
return
|
||||
event.message_obj.message.insert(
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
)
|
||||
new_event = copy.copy(event)
|
||||
self.context.get_event_queue().put_nowait(new_event)
|
||||
event.stop_event()
|
||||
controller.stop()
|
||||
|
||||
try:
|
||||
await empty_mention_waiter(event)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
finally:
|
||||
event.stop_event()
|
||||
except Exception as e:
|
||||
logger.error("handle_empty_mention error: " + str(e))
|
||||
|
||||
def ltm_enabled(self, event: AstrMessageEvent):
|
||||
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: astrbot
|
||||
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
|
||||
author: Soulter
|
||||
version: 4.1.0
|
||||
desc: AstrBot's internal plugin, providing some basic capabilities.
|
||||
author: AstrBot Team
|
||||
version: 4.1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: builtin_commands
|
||||
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
|
||||
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
|
||||
author: Soulter
|
||||
version: 0.0.1
|
||||
@@ -1,115 +0,0 @@
|
||||
import copy
|
||||
from sys import maxsize
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.star import Context, Star
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
FILTERS,
|
||||
USER_SESSIONS,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
|
||||
class Main(Star):
|
||||
"""会话控制"""
|
||||
|
||||
def __init__(self, context: Context) -> None:
|
||||
super().__init__(context)
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
|
||||
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
|
||||
"""会话控制代理"""
|
||||
for session_filter in FILTERS:
|
||||
session_id = session_filter.filter(event)
|
||||
if session_id in USER_SESSIONS:
|
||||
await SessionWaiter.trigger(session_id, event)
|
||||
event.stop_event()
|
||||
|
||||
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
|
||||
async def handle_empty_mention(self, event: AstrMessageEvent):
|
||||
"""实现了对只有一个 @ 的消息内容的处理"""
|
||||
try:
|
||||
messages = event.get_messages()
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
p_settings = cfg["platform_settings"]
|
||||
wake_prefix = cfg.get("wake_prefix", [])
|
||||
if len(messages) == 1:
|
||||
if (
|
||||
isinstance(messages[0], Comp.At)
|
||||
and str(messages[0].qq) == str(event.get_self_id())
|
||||
and p_settings.get("empty_mention_waiting", True)
|
||||
) or (
|
||||
isinstance(messages[0], Comp.Plain)
|
||||
and messages[0].text.strip() in wake_prefix
|
||||
):
|
||||
if p_settings.get("empty_mention_waiting_need_reply", True):
|
||||
try:
|
||||
# 尝试使用 LLM 生成更生动的回复
|
||||
# func_tools_mgr = self.context.get_llm_tool_manager()
|
||||
|
||||
# 获取用户当前的对话信息
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
conversation = None
|
||||
|
||||
if curr_cid:
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
event.unified_msg_origin,
|
||||
curr_cid,
|
||||
)
|
||||
else:
|
||||
# 创建新对话
|
||||
curr_cid = await self.context.conversation_manager.new_conversation(
|
||||
event.unified_msg_origin,
|
||||
platform_id=event.get_platform_id(),
|
||||
)
|
||||
|
||||
# 使用 LLM 生成回复
|
||||
yield event.request_llm(
|
||||
prompt=(
|
||||
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
|
||||
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
|
||||
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
|
||||
),
|
||||
session_id=curr_cid,
|
||||
contexts=[],
|
||||
system_prompt="",
|
||||
conversation=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM response failed: {e!s}")
|
||||
# LLM 回复失败,使用原始预设回复
|
||||
yield event.plain_result("想要问什么呢?😄")
|
||||
|
||||
@session_waiter(60)
|
||||
async def empty_mention_waiter(
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
) -> None:
|
||||
if not event.message_str or not event.message_str.strip():
|
||||
return
|
||||
event.message_obj.message.insert(
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
)
|
||||
new_event = copy.copy(event)
|
||||
# 重新推入事件队列
|
||||
self.context.get_event_queue().put_nowait(new_event)
|
||||
event.stop_event()
|
||||
controller.stop()
|
||||
|
||||
try:
|
||||
await empty_mention_waiter(event)
|
||||
except TimeoutError as _:
|
||||
pass
|
||||
except Exception as e:
|
||||
yield event.plain_result("发生错误,请联系管理员: " + str(e))
|
||||
finally:
|
||||
event.stop_event()
|
||||
except Exception as e:
|
||||
logger.error("handle_empty_mention error: " + str(e))
|
||||
@@ -1,5 +0,0 @@
|
||||
name: session_controller
|
||||
desc: 为插件支持会话控制
|
||||
author: Cvandia & Soulter
|
||||
version: v1.0.1
|
||||
repo: https://astrbot.app
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 257 icons */
|
||||
/* Auto-generated MDI subset – 259 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -784,6 +784,14 @@
|
||||
content: "\F03F6";
|
||||
}
|
||||
|
||||
.mdi-pin::before {
|
||||
content: "\F0403";
|
||||
}
|
||||
|
||||
.mdi-pin-outline::before {
|
||||
content: "\F0931";
|
||||
}
|
||||
|
||||
.mdi-play::before {
|
||||
content: "\F040A";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPinned: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 定义要发送到父组件的事件
|
||||
@@ -32,6 +36,7 @@ const emit = defineEmits([
|
||||
"view-handlers",
|
||||
"view-readme",
|
||||
"view-changelog",
|
||||
"toggle-pin",
|
||||
]);
|
||||
|
||||
const showUninstallDialog = ref(false);
|
||||
@@ -115,6 +120,10 @@ const viewChangelog = () => {
|
||||
emit("view-changelog", props.extension);
|
||||
};
|
||||
|
||||
const togglePin = () => {
|
||||
emit("toggle-pin", props.extension);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -280,6 +289,22 @@ const viewChangelog = () => {
|
||||
<v-card-actions class="extension-actions" @click.stop>
|
||||
<template v-if="!marketMode">
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props: pinTooltipProps }">
|
||||
<v-btn
|
||||
v-bind="pinTooltipProps"
|
||||
:aria-label="isPinned ? tm('buttons.unpin') : tm('buttons.pin')"
|
||||
:color="isPinned ? 'primary' : 'secondary'"
|
||||
:icon="isPinned ? 'mdi-pin' : 'mdi-pin-outline'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="extension-pin-btn"
|
||||
@click="togglePin"
|
||||
></v-btn>
|
||||
</template>
|
||||
<span>{{ isPinned ? tm("buttons.unpin") : tm("buttons.pin") }}</span>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
@@ -306,20 +331,6 @@ const viewChangelog = () => {
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="extension?.repo" location="top" :text="tm('buttons.viewRepo')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
v-bind="actionProps"
|
||||
icon="mdi-github"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
:href="extension.repo"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" :text="tm('card.actions.reloadPlugin')">
|
||||
<template v-slot:activator="{ props: actionProps }">
|
||||
<v-btn
|
||||
@@ -466,6 +477,10 @@ const viewChangelog = () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-pin-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extension-switch-wrap :deep(.v-switch) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"selectFile": "Select File",
|
||||
"refresh": "Refresh",
|
||||
"updateAll": "Update All",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"deleteSource": "Delete Source",
|
||||
"reshuffle": "Shuffle Again"
|
||||
},
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"selectFile": "Выбрать файл",
|
||||
"refresh": "Обновить",
|
||||
"updateAll": "Обновить все",
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить",
|
||||
"deleteSource": "Удалить источник",
|
||||
"reshuffle": "Мне повезет!"
|
||||
},
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"selectFile": "选择文件",
|
||||
"refresh": "刷新",
|
||||
"updateAll": "更新全部插件",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"deleteSource": "删除源",
|
||||
"reshuffle": "随机一发"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup>
|
||||
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
|
||||
import { normalizeTextInput } from "@/utils/inputValue";
|
||||
import {
|
||||
readPinnedExtensions,
|
||||
writePinnedExtensions,
|
||||
} from "./extensionPreferenceStorage.mjs";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: {
|
||||
@@ -148,6 +153,53 @@ const openPluginDetail = (extension) => {
|
||||
hash: "#installed",
|
||||
});
|
||||
};
|
||||
|
||||
const pinnedExtensionNames = ref(readPinnedExtensions());
|
||||
|
||||
const pinnedExtensionOrder = computed(() => {
|
||||
const order = new Map();
|
||||
pinnedExtensionNames.value.forEach((name, index) => {
|
||||
order.set(name, index);
|
||||
});
|
||||
return order;
|
||||
});
|
||||
|
||||
const sortedInstalledPlugins = computed(() => {
|
||||
const order = pinnedExtensionOrder.value;
|
||||
return [...filteredPlugins.value].sort((a, b) => {
|
||||
const aIndex = order.has(a?.name) ? order.get(a.name) : Number.POSITIVE_INFINITY;
|
||||
const bIndex = order.has(b?.name) ? order.get(b.name) : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
pinnedExtensionNames,
|
||||
(names) => {
|
||||
writePinnedExtensions(names);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const isPinnedExtension = (extension) => {
|
||||
const name = extension?.name;
|
||||
return !!name && pinnedExtensionOrder.value.has(name);
|
||||
};
|
||||
|
||||
const togglePinnedExtension = (extension) => {
|
||||
const name = extension?.name;
|
||||
if (!name) return;
|
||||
|
||||
const next = pinnedExtensionNames.value.filter((item) => item !== name);
|
||||
if (next.length === pinnedExtensionNames.value.length) {
|
||||
next.unshift(name);
|
||||
}
|
||||
pinnedExtensionNames.value = next;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -252,7 +304,7 @@ const openPluginDetail = (extension) => {
|
||||
|
||||
<v-fade-transition hide-on-leave>
|
||||
<div>
|
||||
<v-row v-if="filteredPlugins.length === 0" class="text-center">
|
||||
<v-row v-if="sortedInstalledPlugins.length === 0" class="text-center">
|
||||
<v-col cols="12" class="pa-2">
|
||||
<v-icon size="64" color="info" class="mb-4"
|
||||
>mdi-puzzle-outline</v-icon
|
||||
@@ -274,9 +326,11 @@ const openPluginDetail = (extension) => {
|
||||
>
|
||||
<ExtensionCard
|
||||
:extension="extension"
|
||||
:is-pinned="isPinnedExtension(extension)"
|
||||
class="rounded-lg"
|
||||
style="background-color: rgb(var(--v-theme-mcpCardBg))"
|
||||
@click="openPluginDetail(extension)"
|
||||
@toggle-pin="togglePinnedExtension(extension)"
|
||||
@configure="openExtensionConfig(extension.name)"
|
||||
@uninstall="
|
||||
(ext, options) => uninstallExtension(ext.name, options)
|
||||
|
||||
89
dashboard/src/views/extension/extensionPreferenceStorage.mjs
Normal file
89
dashboard/src/views/extension/extensionPreferenceStorage.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
export const PINNED_EXTENSIONS_STORAGE_KEY = "astrbot.pinnedExtensions";
|
||||
|
||||
const getStorageForRead = (storageOverride) => {
|
||||
if (storageOverride === null) {
|
||||
return null;
|
||||
}
|
||||
if (storageOverride !== undefined) {
|
||||
return typeof storageOverride?.getItem === "function"
|
||||
? storageOverride
|
||||
: null;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const localStorage = window.localStorage ?? null;
|
||||
return typeof localStorage?.getItem === "function" ? localStorage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageForWrite = (storageOverride) => {
|
||||
if (storageOverride === null) {
|
||||
return null;
|
||||
}
|
||||
if (storageOverride !== undefined) {
|
||||
return typeof storageOverride?.setItem === "function"
|
||||
? storageOverride
|
||||
: null;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const localStorage = window.localStorage ?? null;
|
||||
return typeof localStorage?.setItem === "function" ? localStorage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePinnedExtensions = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return value
|
||||
.filter((item) => typeof item === "string" && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => {
|
||||
if (seen.has(item)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(item);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const readPinnedExtensions = (storage) => {
|
||||
const targetStorage = getStorageForRead(storage);
|
||||
if (!targetStorage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = targetStorage.getItem(PINNED_EXTENSIONS_STORAGE_KEY);
|
||||
return normalizePinnedExtensions(raw ? JSON.parse(raw) : []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const writePinnedExtensions = (names, storage) => {
|
||||
const targetStorage = getStorageForWrite(storage);
|
||||
if (!targetStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
targetStorage.setItem(
|
||||
PINNED_EXTENSIONS_STORAGE_KEY,
|
||||
JSON.stringify(normalizePinnedExtensions(names)),
|
||||
);
|
||||
} catch {
|
||||
// Ignore restricted storage environments.
|
||||
}
|
||||
};
|
||||
54
dashboard/tests/extensionPreferenceStorage.test.mjs
Normal file
54
dashboard/tests/extensionPreferenceStorage.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
PINNED_EXTENSIONS_STORAGE_KEY,
|
||||
readPinnedExtensions,
|
||||
writePinnedExtensions,
|
||||
} from '../src/views/extension/extensionPreferenceStorage.mjs';
|
||||
|
||||
test('readPinnedExtensions uses the legacy pinned extension storage key', () => {
|
||||
assert.equal(PINNED_EXTENSIONS_STORAGE_KEY, 'astrbot.pinnedExtensions');
|
||||
});
|
||||
|
||||
test('readPinnedExtensions parses stored pinned extension names', () => {
|
||||
const storage = {
|
||||
getItem(key) {
|
||||
return key === PINNED_EXTENSIONS_STORAGE_KEY
|
||||
? JSON.stringify(['alpha', 'beta', 'alpha', '', 1])
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(readPinnedExtensions(storage), ['alpha', 'beta']);
|
||||
});
|
||||
|
||||
test('readPinnedExtensions returns an empty array when storage access fails', () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('SecurityError');
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(readPinnedExtensions(storage), []);
|
||||
});
|
||||
|
||||
test('writePinnedExtensions stores normalized pinned extension names', () => {
|
||||
const writes = [];
|
||||
const storage = {
|
||||
setItem(key, value) {
|
||||
writes.push([key, value]);
|
||||
},
|
||||
};
|
||||
|
||||
writePinnedExtensions(['alpha', 'beta', 'alpha', '', null], storage);
|
||||
|
||||
assert.deepEqual(writes, [
|
||||
[PINNED_EXTENSIONS_STORAGE_KEY, JSON.stringify(['alpha', 'beta'])],
|
||||
]);
|
||||
});
|
||||
|
||||
test('writePinnedExtensions ignores unavailable storage', () => {
|
||||
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], null));
|
||||
assert.doesNotThrow(() => writePinnedExtensions(['alpha'], {}));
|
||||
});
|
||||
Reference in New Issue
Block a user