mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(redis): auto-format detected JSON values
This commit is contained in:
@@ -28,14 +28,16 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { formatTtl } from "@/lib/common/ttlFormat";
|
||||
import { computeDisplayTtl, computeTtlCountdownTick, computeTtlCountdownValue, computeTtlForExpiryEdit, DEFAULT_REDIS_AUTO_REFRESH_INTERVAL_SECONDS, normalizeRedisAutoRefreshInterval } from "@/lib/redis/redisAutoRefresh";
|
||||
import {
|
||||
autoRedisValueFormat,
|
||||
canRenderRedisValueFormat,
|
||||
canEditRedisMemberDetail,
|
||||
decodeRedisBlob,
|
||||
detectedRedisStructuredCodec,
|
||||
formatRedisMemberDetail,
|
||||
getRedisMemberSelectionKey,
|
||||
isRedisBlob,
|
||||
isRedisJsonContainerValue,
|
||||
parseRedisJsonDetail,
|
||||
preferredRedisValueFormat,
|
||||
REDIS_VALUE_FORMAT_DISPLAY_ORDER,
|
||||
redisBlobText,
|
||||
redisCollectionPageItems,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
REDIS_VALUE_CODEC_ORDER,
|
||||
type RedisCollectionItem,
|
||||
type RedisHashRowCopyTarget,
|
||||
type RedisMemberDetail,
|
||||
type RedisValueCodec,
|
||||
type RedisValueFormat,
|
||||
} from "@/lib/redis/redisValuePresentation";
|
||||
@@ -193,10 +196,10 @@ const isResizingHashColumns = ref(false);
|
||||
const zsetTableRef = ref<HTMLElement | null>(null);
|
||||
const zsetScoreWidth = ref(220);
|
||||
const isResizingZsetColumns = ref(false);
|
||||
const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const stringValueCodec = ref<RedisValueCodec>(readPreferredRedisValueCodec());
|
||||
const memberValueCodec = ref<RedisValueCodec>(readPreferredRedisValueCodec());
|
||||
const stringValueView = ref<RedisValueFormat>(readStoredRedisValueFormat() ?? "utf8");
|
||||
const memberValueView = ref<RedisValueFormat>(readStoredRedisValueFormat() ?? "utf8");
|
||||
const stringValueCodec = ref<RedisValueCodec>(readStoredRedisValueCodec() ?? "none");
|
||||
const memberValueCodec = ref<RedisValueCodec>(readStoredRedisValueCodec() ?? "none");
|
||||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonUnicodeMode = ref(readRedisJsonUnicodeMode());
|
||||
const redisJsonDecoded = computed(() => redisJsonUnicodeMode.value === "decoded");
|
||||
@@ -479,6 +482,40 @@ watch([memberValueCodec, selectedMemberRaw], ([codec]) => {
|
||||
const stringGzipBadge = computed(() => (stringBlob.value ? isGzipMagic(decodeRedisBlob(stringBlob.value)) : false));
|
||||
const memberGzipBadge = computed(() => (isRedisBlob(selectedMemberRaw.value) ? isGzipMagic(decodeRedisBlob(selectedMemberRaw.value)) : false));
|
||||
|
||||
/**
|
||||
* Badge label for the detected payload representation (issue #10922): JSON
|
||||
* containers and deterministic binary formats the user would otherwise see
|
||||
* as an opaque blob. Plain strings and scalar JSON get no badge.
|
||||
*/
|
||||
function redisDetectionBadgeLabel(detail: RedisMemberDetail | null): string | null {
|
||||
if (!detail) return null;
|
||||
if (detail.javaSerialized) return t("redis.detectedJavaSerialized");
|
||||
if (detail.pickle) return t("redis.detectedPickle");
|
||||
if (detail.msgpack) return t("redis.detectedMsgpack");
|
||||
if (detail.phpSerialized) return t("redis.detectedPhpSerialized");
|
||||
if (isRedisJsonContainerValue(detail.json?.value)) return "JSON";
|
||||
return null;
|
||||
}
|
||||
|
||||
const stringValueDetectionLabel = computed(() => redisDetectionBadgeLabel(stringValueDetail.value));
|
||||
const memberDetectionLabel = computed(() => redisDetectionBadgeLabel(selectedMemberDetail.value));
|
||||
|
||||
/**
|
||||
* Issue #10922: payloads with a deterministic container format open decoded
|
||||
* instead of as an opaque blob — but only while the user has not pinned a
|
||||
* codec; any explicit codec choice (including "none") disables detection.
|
||||
*/
|
||||
function applyDetectedStringCodec(detail: RedisMemberDetail) {
|
||||
if (isStringValueTruncated.value) return;
|
||||
if (readStoredRedisValueCodec() != null) return;
|
||||
stringValueCodec.value = detectedRedisStructuredCodec(detail) ?? "none";
|
||||
}
|
||||
|
||||
function applyDetectedMemberCodec(detail: RedisMemberDetail) {
|
||||
if (readStoredRedisValueCodec() != null) return;
|
||||
memberValueCodec.value = detectedRedisStructuredCodec(detail) ?? "none";
|
||||
}
|
||||
|
||||
interface RedisDecodedDetail {
|
||||
formattedText: string;
|
||||
value: unknown;
|
||||
@@ -1275,22 +1312,27 @@ function rememberRedisJsonUnicodeMode(mode: RedisJsonUnicodeMode) {
|
||||
}
|
||||
}
|
||||
|
||||
function readPreferredRedisValueFormat(): RedisValueFormat {
|
||||
/**
|
||||
* The persisted format preference, or null when the user never pinned one.
|
||||
* Null is the auto-detection signal for issue #10922: detected JSON opens in
|
||||
* the JSON view instead of raw UTF-8. Legacy values ("raw") map like before.
|
||||
*/
|
||||
function readStoredRedisValueFormat(): RedisValueFormat | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(REDIS_VALUE_FORMAT_STORAGE_KEY);
|
||||
if (stored === "raw") return "utf8";
|
||||
return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "hex" || stored === "base64" ? stored : "utf8";
|
||||
return stored === "utf8" || stored === "ascii" || stored === "binary" || stored === "json" || stored === "hex" || stored === "base64" ? stored : null;
|
||||
} catch {
|
||||
return "utf8";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPreferredRedisValueCodec(): RedisValueCodec {
|
||||
function readStoredRedisValueCodec(): RedisValueCodec | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(REDIS_VALUE_CODEC_STORAGE_KEY);
|
||||
return isRedisValueCodec(stored) ? stored : "none";
|
||||
return isRedisValueCodec(stored) ? stored : null;
|
||||
} catch {
|
||||
return "none";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1634,7 +1676,13 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
|
||||
const allowJsonText = loadedValue.data.kind === "string";
|
||||
if (loadedValue.data.truncated) stringValueCodec.value = "none";
|
||||
const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText });
|
||||
stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText });
|
||||
// Issue #10922: detected JSON opens pretty-printed, detected structured
|
||||
// codecs open decoded — unless the user pinned an explicit format/codec.
|
||||
// Bitmap payloads render through the string pipeline but are never JSON
|
||||
// or a structured codec, so detection only runs for plain strings.
|
||||
stringValueView.value = autoRedisValueFormat(detail, readStoredRedisValueFormat());
|
||||
if (allowJsonText) applyDetectedStringCodec(detail);
|
||||
|
||||
stringJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
stringJsonDraftBaseline.value = jsonDraftBaseline(stringJsonRawBaseline.value, redisJsonDecoded.value);
|
||||
editValue.value = stringValueView.value === "json" && detail.json ? stringJsonDraftBaseline.value : detail.rawText;
|
||||
@@ -1936,7 +1984,8 @@ function selectMember(title: string, value: unknown, context: RedisMemberContext
|
||||
selectedMemberKey.value = getRedisMemberSelectionKey(title, value, identity);
|
||||
selectedMemberContext.value = context;
|
||||
isEditingMember.value = false;
|
||||
memberValueView.value = preferredRedisValueFormat(value, readPreferredRedisValueFormat(), { allowJsonText: true });
|
||||
memberValueView.value = autoRedisValueFormat(detail, readStoredRedisValueFormat());
|
||||
applyDetectedMemberCodec(detail);
|
||||
memberJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, redisJsonDecoded.value);
|
||||
memberEditValue.value = memberValueView.value === "json" && detail.json ? memberJsonDraftBaseline.value : detail.rawText;
|
||||
@@ -2889,6 +2938,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
|
||||
{{ redisCodecLabel(codec) }}
|
||||
</Button>
|
||||
</RedisHorizontalScrollbar>
|
||||
<Badge v-if="stringValueDetectionLabel" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.detectedBadgeTitle')" :aria-label="t('redis.detectedBadgeTitle')">{{ stringValueDetectionLabel }}</Badge>
|
||||
<FileArchive v-if="!isStringValueTruncated && stringGzipBadge && stringValueCodec === 'none'" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')" />
|
||||
<span class="flex-1" />
|
||||
<label v-if="isTextRedisFormat(stringValueView) || activeStructuredStringDetail || isDecompressCodec(stringValueCodec)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
@@ -3671,6 +3721,7 @@ useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || saving
|
||||
<Input v-if="isEditingMember && selectedMemberContext?.kind === 'hash'" v-model="memberFieldEditValue" class="h-7 min-w-0 flex-1 text-sm" :placeholder="t('redis.field')" :aria-label="t('redis.field')" @keydown.enter="saveMemberEdit" />
|
||||
<span v-else class="truncate">{{ selectedMemberTitle ? formatValue(selectedMemberTitle) : t("redis.memberDetail") }}</span>
|
||||
<Badge variant="outline" class="shrink-0 text-xs">{{ redisFormatLabel(memberValueView, selectedMemberDetail.rawLabel) }}</Badge>
|
||||
<Badge v-if="memberDetectionLabel" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.detectedBadgeTitle')" :aria-label="t('redis.detectedBadgeTitle')">{{ memberDetectionLabel }}</Badge>
|
||||
<Badge v-if="memberGzipBadge && memberValueCodec === 'none'" variant="outline" class="shrink-0 text-xs text-muted-foreground" :title="t('redis.gzipBadgeTitle')" :aria-label="t('redis.gzipBadgeTitle')">
|
||||
<FileArchive class="h-3 w-3 mr-1" />
|
||||
Gzip
|
||||
|
||||
@@ -5635,6 +5635,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "DEFLATE kimi yenidən cəhd et",
|
||||
decompressedLimitExceeded: "Sıxılması açılmış nəticə 50 MiB həddini aşır",
|
||||
gzipBadgeTitle: "Gzip ilə sıxılıb — sıxılması açılmış məzmuna baxmaq üçün klikləyin",
|
||||
detectedBadgeTitle: "Aşkarlanmış məlumat formatı",
|
||||
detectedJavaSerialized: "Java serializasiyası",
|
||||
detectedPickle: "Pickle serializasiyası",
|
||||
detectedMsgpack: "Msgpack serializasiyası",
|
||||
detectedPhpSerialized: "PHP serializasiyası",
|
||||
formatJson: "Formatla",
|
||||
compressJson: "Sıx",
|
||||
jsonFormatError: "Yanlış JSON formatı",
|
||||
|
||||
@@ -6368,6 +6368,11 @@ export default {
|
||||
decompressedRetryAsDeflate: "Retry as DEFLATE",
|
||||
decompressedLimitExceeded: "Decompressed result exceeds the 50 MiB limit",
|
||||
gzipBadgeTitle: "Gzip compressed — click to view decompressed content",
|
||||
detectedBadgeTitle: "Detected data format",
|
||||
detectedJavaSerialized: "Java serialized",
|
||||
detectedPickle: "Pickle serialized",
|
||||
detectedMsgpack: "Msgpack serialized",
|
||||
detectedPhpSerialized: "PHP serialized",
|
||||
formatJson: "Format",
|
||||
compressJson: "Compress",
|
||||
jsonFormatError: "Invalid JSON format",
|
||||
|
||||
@@ -5863,6 +5863,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Reintentar como DEFLATE",
|
||||
decompressedLimitExceeded: "El resultado descomprimido supera el límite de 50 MiB",
|
||||
gzipBadgeTitle: "Comprimido con Gzip: haga clic para ver el contenido descomprimido",
|
||||
detectedBadgeTitle: "Formato de datos detectado",
|
||||
detectedJavaSerialized: "Serializado en Java",
|
||||
detectedPickle: "Serializado con Pickle",
|
||||
detectedMsgpack: "Serializado con Msgpack",
|
||||
detectedPhpSerialized: "Serializado en PHP",
|
||||
clearHistory: "Borrar historial de comandos",
|
||||
historyCleared: "Historial de comandos Redis borrado",
|
||||
blockedCommand: "El comando {command} está bloqueado por seguridad. Desactiva el icono de escudo en la barra de herramientas para permitirlo.",
|
||||
|
||||
@@ -5680,6 +5680,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Riprova come DEFLATE",
|
||||
decompressedLimitExceeded: "Il risultato decompresso supera il limite di 50 MiB",
|
||||
gzipBadgeTitle: "Compresso con Gzip: fare clic per visualizzare il contenuto decompresso",
|
||||
detectedBadgeTitle: "Formato dati rilevato",
|
||||
detectedJavaSerialized: "Serializzato in Java",
|
||||
detectedPickle: "Serializzato con Pickle",
|
||||
detectedMsgpack: "Serializzato con Msgpack",
|
||||
detectedPhpSerialized: "Serializzato in PHP",
|
||||
formatJson: "Formatta",
|
||||
compressJson: "Comprimi",
|
||||
jsonFormatError: "Formato JSON non valido",
|
||||
|
||||
@@ -5708,6 +5708,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Deflate で再試行",
|
||||
decompressedLimitExceeded: "解凍結果が 50 MiB の上限を超えています",
|
||||
gzipBadgeTitle: "Gzip 圧縮 — クリックで解凍内容を表示",
|
||||
detectedBadgeTitle: "検出されたデータ形式",
|
||||
detectedJavaSerialized: "Javaシリアライズ",
|
||||
detectedPickle: "Pickleシリアライズ",
|
||||
detectedMsgpack: "Msgpackシリアライズ",
|
||||
detectedPhpSerialized: "PHPシリアライズ",
|
||||
formatJson: "フォーマット",
|
||||
compressJson: "圧縮",
|
||||
jsonFormatError: "不正なJSON形式です",
|
||||
|
||||
@@ -5342,6 +5342,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Deflate로 재시도",
|
||||
decompressedLimitExceeded: "압축 해제 결과가 50 MiB 제한을 초과합니다",
|
||||
gzipBadgeTitle: "Gzip 압축 — 클릭하여 압축 해제 내용 보기",
|
||||
detectedBadgeTitle: "감지된 데이터 형식",
|
||||
detectedJavaSerialized: "Java 직렬화",
|
||||
detectedPickle: "Pickle 직렬화",
|
||||
detectedMsgpack: "Msgpack 직렬화",
|
||||
detectedPhpSerialized: "PHP 직렬화",
|
||||
formatJson: "서식",
|
||||
compressJson: "압축",
|
||||
jsonFormatError: "잘못된 JSON 형식",
|
||||
|
||||
@@ -5682,6 +5682,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Tentar novamente como DEFLATE",
|
||||
decompressedLimitExceeded: "O resultado descomprimido excede o limite de 50 MiB",
|
||||
gzipBadgeTitle: "Comprimido com Gzip — clique para ver o conteúdo descomprimido",
|
||||
detectedBadgeTitle: "Formato de dados detectado",
|
||||
detectedJavaSerialized: "Serializado em Java",
|
||||
detectedPickle: "Serializado com Pickle",
|
||||
detectedMsgpack: "Serializado com Msgpack",
|
||||
detectedPhpSerialized: "Serializado em PHP",
|
||||
formatJson: "Formatar",
|
||||
compressJson: "Comprimir",
|
||||
jsonFormatError: "Formato JSON inválido",
|
||||
|
||||
@@ -6712,6 +6712,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "Повторить как DEFLATE",
|
||||
decompressedLimitExceeded: "Распакованный результат превышает лимит 50 MiB",
|
||||
gzipBadgeTitle: "Сжато Gzip — нажмите, чтобы посмотреть распакованное содержимое",
|
||||
detectedBadgeTitle: "Обнаруженный формат данных",
|
||||
detectedJavaSerialized: "Java-сериализация",
|
||||
detectedPickle: "Pickle-сериализация",
|
||||
detectedMsgpack: "Msgpack-сериализация",
|
||||
detectedPhpSerialized: "PHP-сериализация",
|
||||
formatJson: "Форматировать",
|
||||
compressJson: "Сжать",
|
||||
jsonFormatError: "Недопустимый формат JSON",
|
||||
|
||||
@@ -5529,6 +5529,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "DEFLATE olarak yeniden dene",
|
||||
decompressedLimitExceeded: "Sıkıştırması açılan sonuç 50 MiB sınırını aşıyor",
|
||||
gzipBadgeTitle: "Gzip ile sıkıştırılmış — açılmış içeriği görmek için tıklayın",
|
||||
detectedBadgeTitle: "Algılanan veri biçimi",
|
||||
detectedJavaSerialized: "Java serileştirilmiş",
|
||||
detectedPickle: "Pickle serileştirilmiş",
|
||||
detectedMsgpack: "Msgpack serileştirilmiş",
|
||||
detectedPhpSerialized: "PHP serileştirilmiş",
|
||||
formatJson: "Biçim",
|
||||
compressJson: "Sıkıştır",
|
||||
jsonFormatError: "Geçersiz JSON biçimi",
|
||||
|
||||
@@ -6341,6 +6341,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "以 Deflate 重试",
|
||||
decompressedLimitExceeded: "解压结果超过 50 MiB 限制",
|
||||
gzipBadgeTitle: "Gzip 压缩 — 点击查看解压内容",
|
||||
detectedBadgeTitle: "检测到的数据格式",
|
||||
detectedJavaSerialized: "Java 序列化",
|
||||
detectedPickle: "Pickle 序列化",
|
||||
detectedMsgpack: "Msgpack 序列化",
|
||||
detectedPhpSerialized: "PHP 序列化",
|
||||
formatJson: "格式化",
|
||||
compressJson: "压缩",
|
||||
jsonFormatError: "JSON 格式不合法",
|
||||
|
||||
@@ -4995,6 +4995,11 @@ export default withEnglishFallback({
|
||||
decompressedRetryAsDeflate: "以 Deflate 重試",
|
||||
decompressedLimitExceeded: "解壓結果超過 50 MiB 限制",
|
||||
gzipBadgeTitle: "Gzip 壓縮 — 點擊檢視解壓內容",
|
||||
detectedBadgeTitle: "偵測到的資料格式",
|
||||
detectedJavaSerialized: "Java 序列化",
|
||||
detectedPickle: "Pickle 序列化",
|
||||
detectedMsgpack: "Msgpack 序列化",
|
||||
detectedPhpSerialized: "PHP 序列化",
|
||||
formatJson: "格式化",
|
||||
compressJson: "壓縮",
|
||||
jsonFormatError: "無效的 JSON 格式",
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "vue/compiler-sfc";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8");
|
||||
const parsedViewer = parse(viewerSource, { filename: "RedisValueViewer.vue" });
|
||||
|
||||
/**
|
||||
* Issue #10922 wiring: detected JSON containers open pretty-printed, detected
|
||||
* structured codecs open decoded, and both surfaces carry a representation
|
||||
* badge — unless the user pinned an explicit format or codec.
|
||||
*/
|
||||
describe("Redis value detection (issue 10922)", () => {
|
||||
function findFunction(name: string): ts.FunctionDeclaration {
|
||||
const script = parsedViewer.descriptor.scriptSetup;
|
||||
expect(script).toBeDefined();
|
||||
|
||||
const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const declaration = source.statements.find((statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && statement.name?.text === name);
|
||||
expect(declaration).toBeDefined();
|
||||
return declaration!;
|
||||
}
|
||||
|
||||
function callsIn(node: ts.Node): ts.CallExpression[] {
|
||||
const calls: ts.CallExpression[] = [];
|
||||
const visit = (child: ts.Node) => {
|
||||
if (ts.isCallExpression(child)) calls.push(child);
|
||||
ts.forEachChild(child, visit);
|
||||
};
|
||||
visit(node);
|
||||
return calls;
|
||||
}
|
||||
|
||||
function calledName(call: ts.CallExpression): string | undefined {
|
||||
if (ts.isIdentifier(call.expression)) return call.expression.text;
|
||||
if (ts.isPropertyAccessExpression(call.expression)) return `${call.expression.expression.getText()}.${call.expression.name.text}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assignmentsIn(node: ts.Node): ts.BinaryExpression[] {
|
||||
const assignments: ts.BinaryExpression[] = [];
|
||||
const visit = (child: ts.Node) => {
|
||||
if (ts.isBinaryExpression(child) && child.operatorToken.kind === ts.SyntaxKind.EqualsToken) assignments.push(child);
|
||||
ts.forEachChild(child, visit);
|
||||
};
|
||||
visit(node);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
function findVariableInitializer(name: string): ts.Expression {
|
||||
const script = parsedViewer.descriptor.scriptSetup;
|
||||
expect(script).toBeDefined();
|
||||
|
||||
const source = ts.createSourceFile("RedisValueViewer.vue.ts", script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
for (const statement of source.statements) {
|
||||
if (!ts.isVariableStatement(statement)) continue;
|
||||
const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name);
|
||||
if (declaration?.initializer) return declaration.initializer;
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${name} to have an initializer`);
|
||||
}
|
||||
|
||||
it("opens detected JSON containers pretty-printed and detected codecs decoded on load", () => {
|
||||
const load = findFunction("load");
|
||||
const formatAssignments = assignmentsIn(load).filter((assignment) => assignment.left.getText() === "stringValueView.value");
|
||||
const codecCalls = callsIn(load).filter((call) => calledName(call) === "applyDetectedStringCodec");
|
||||
|
||||
expect(formatAssignments).toHaveLength(1);
|
||||
expect(formatAssignments[0].right.getText()).toBe("autoRedisValueFormat(detail, readStoredRedisValueFormat())");
|
||||
expect(codecCalls.map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["detail"]);
|
||||
});
|
||||
|
||||
it("applies the same detection to selected member details", () => {
|
||||
const selectMember = findFunction("selectMember");
|
||||
const formatAssignments = assignmentsIn(selectMember).filter((assignment) => assignment.left.getText() === "memberValueView.value");
|
||||
const codecCalls = callsIn(selectMember).filter((call) => calledName(call) === "applyDetectedMemberCodec");
|
||||
|
||||
expect(formatAssignments).toHaveLength(1);
|
||||
expect(formatAssignments[0].right.getText()).toBe("autoRedisValueFormat(detail, readStoredRedisValueFormat())");
|
||||
expect(codecCalls.map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["detail"]);
|
||||
});
|
||||
|
||||
it("detection yields to a pinned codec and clears itself for undetected payloads", () => {
|
||||
const applyString = findFunction("applyDetectedStringCodec").getText();
|
||||
const applyMember = findFunction("applyDetectedMemberCodec").getText();
|
||||
|
||||
for (const text of [applyString, applyMember]) {
|
||||
expect(text).toContain("readStoredRedisValueCodec() != null");
|
||||
expect(text).toContain('detectedRedisStructuredCodec(detail) ?? "none"');
|
||||
}
|
||||
expect(applyString).toContain("isStringValueTruncated.value");
|
||||
});
|
||||
|
||||
it("treats a missing stored preference as the auto-detection signal", () => {
|
||||
const readFormat = findFunction("readStoredRedisValueFormat").getText();
|
||||
const readCodec = findFunction("readStoredRedisValueCodec").getText();
|
||||
|
||||
// Legacy stored values keep their mapping; anything unpinned returns null
|
||||
// (auto), never the old "utf8"/"none" defaults.
|
||||
expect(readFormat).toContain('if (stored === "raw") return "utf8";');
|
||||
expect(readFormat).toContain(": null;");
|
||||
expect(readFormat).not.toContain(': "utf8";');
|
||||
expect(readCodec).toContain("isRedisValueCodec(stored) ? stored : null");
|
||||
|
||||
const stringViewInit = findVariableInitializer("stringValueView").getText();
|
||||
const stringCodecInit = findVariableInitializer("stringValueCodec").getText();
|
||||
expect(stringViewInit).toContain('readStoredRedisValueFormat() ?? "utf8"');
|
||||
expect(stringCodecInit).toContain('readStoredRedisValueCodec() ?? "none"');
|
||||
});
|
||||
|
||||
it("labels the detected representation next to the value on both surfaces", () => {
|
||||
const template = parsedViewer.descriptor.template;
|
||||
expect(parsedViewer.errors).toEqual([]);
|
||||
expect(template).toBeDefined();
|
||||
|
||||
expect(viewerSource).toContain('v-if="stringValueDetectionLabel"');
|
||||
expect(viewerSource).toContain('v-if="memberDetectionLabel"');
|
||||
expect(viewerSource.match(/t\('redis\.detectedBadgeTitle'\)/g)).toHaveLength(4);
|
||||
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain('t("redis.detectedJavaSerialized")');
|
||||
expect(findFunction("redisDetectionBadgeLabel").getText()).toContain("isRedisJsonContainerValue(detail.json?.value)");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
autoRedisValueFormat,
|
||||
canRenderRedisValueFormat,
|
||||
detectedRedisStructuredCodec,
|
||||
formatRedisMemberDetail,
|
||||
formatRedisStringValue,
|
||||
getRedisMemberSelectionKey,
|
||||
isRedisJsonContainerValue,
|
||||
jsonToXmlText,
|
||||
jsonToYamlText,
|
||||
normalizeRedisJsonDraft,
|
||||
@@ -276,6 +279,69 @@ describe("redisValuePresentation", () => {
|
||||
expect(canRenderRedisValueFormat(formatRedisMemberDetail("plain-text"), "utf8")).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-opens JSON containers pretty-printed when no format was pinned", () => {
|
||||
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
|
||||
expect(autoRedisValueFormat(detail, null)).toBe("json");
|
||||
});
|
||||
|
||||
it("keeps scalar JSON and plain text on the raw utf8 view", () => {
|
||||
expect(autoRedisValueFormat(formatRedisMemberDetail("123", { allowJsonText: true }), null)).toBe("utf8");
|
||||
expect(autoRedisValueFormat(formatRedisMemberDetail("plain-text", { allowJsonText: true }), null)).toBe("utf8");
|
||||
});
|
||||
|
||||
it("lets a pinned format preference win over JSON detection", () => {
|
||||
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
|
||||
expect(autoRedisValueFormat(detail, "utf8")).toBe("utf8");
|
||||
expect(autoRedisValueFormat(detail, "json")).toBe("json");
|
||||
});
|
||||
|
||||
it("keeps non-reusable view preferences falling back to the value default", () => {
|
||||
const detail = formatRedisMemberDetail('{"role":"reader"}', { allowJsonText: true });
|
||||
expect(autoRedisValueFormat(detail, "hex")).toBe("utf8");
|
||||
});
|
||||
|
||||
it("still honors binary inspection views on non-editable blobs", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: Buffer.from([0x00, 0x01, 0x02]).toString("base64"),
|
||||
encoding: "binary",
|
||||
});
|
||||
|
||||
expect(autoRedisValueFormat(detail, "hex")).toBe("hex");
|
||||
expect(autoRedisValueFormat(detail, null)).toBe("hex");
|
||||
});
|
||||
|
||||
it("maps detected structured payloads to their codec", () => {
|
||||
const java = formatRedisMemberDetail({ raw_base64: "rO0ABXQACHNvbWV0ZXh0", encoding: "binary" });
|
||||
expect(detectedRedisStructuredCodec(java)).toBe("javaserialize");
|
||||
|
||||
const plain = formatRedisMemberDetail("plain-text", { allowJsonText: true });
|
||||
expect(detectedRedisStructuredCodec(plain)).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-detects msgpack only when the decoded value is a container", () => {
|
||||
// {"a":1} encoded as msgpack.
|
||||
const container = formatRedisMemberDetail({
|
||||
raw_base64: Buffer.from([0x81, 0xa1, 0x61, 0x01]).toString("base64"),
|
||||
encoding: "binary",
|
||||
});
|
||||
expect(detectedRedisStructuredCodec(container)).toBe("msgpack");
|
||||
|
||||
// Single ASCII bytes are valid msgpack fixints, so scalar payloads must
|
||||
// stay undetected: a counter key of "1" must not render as 49.
|
||||
for (const scalar of ["1", "0", "Y", "é"]) {
|
||||
const detail = formatRedisMemberDetail(scalar, { allowJsonText: true });
|
||||
expect(detectedRedisStructuredCodec(detail)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats only objects and arrays as JSON containers", () => {
|
||||
expect(isRedisJsonContainerValue({ a: 1 })).toBe(true);
|
||||
expect(isRedisJsonContainerValue([1])).toBe(true);
|
||||
expect(isRedisJsonContainerValue("123")).toBe(false);
|
||||
expect(isRedisJsonContainerValue(null)).toBe(false);
|
||||
expect(isRedisJsonContainerValue(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps legacy Pickle payloads out of conservative auto-detection", () => {
|
||||
const detail = formatRedisMemberDetail({
|
||||
raw_base64: "KGRwMApWc3RhdHVzCnAxClZTVUNDRVNTCnAyCnNWcmVzdWx0CnAzCihscDQKSTEKYVZoZWxsbwpwNQphTmFJMDEKYUkwMAphcy4=",
|
||||
|
||||
@@ -461,6 +461,40 @@ export function preferredRedisValueFormat(value: unknown, preferred?: RedisValue
|
||||
return detail.defaultFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #10922: a string payload that parses into a JSON object or array opens
|
||||
* pretty-printed instead of raw, matching RedisInsight. `stored` is the
|
||||
* persisted format preference; null means the user never pinned a format, so
|
||||
* detection wins. Scalar JSON ("123", "\"text\"") renders identically in both
|
||||
* views, so it never triggers the switch.
|
||||
*/
|
||||
export function autoRedisValueFormat(detail: RedisMemberDetail, stored: RedisValueFormat | null): RedisValueFormat {
|
||||
if (stored != null && shouldReuseRedisValueFormatPreference(detail, stored)) return stored;
|
||||
if (stored == null && isRedisJsonContainerValue(detail.json?.value)) return "json";
|
||||
return detail.defaultFormat;
|
||||
}
|
||||
|
||||
export function isRedisJsonContainerValue(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codec that `formatRedisMemberDetail` already decoded for this payload, or
|
||||
* null. Java serialization, Pickle, and PHP serialization have deterministic
|
||||
* magic prefixes; Protobuf is deliberately excluded because without a schema
|
||||
* its wire format matches too many byte strings. Msgpack has no magic prefix
|
||||
* -- any single byte is a valid msgpack scalar -- so its detection
|
||||
* additionally requires the decoded value to be a container, matching the
|
||||
* JSON auto-format guard above.
|
||||
*/
|
||||
export function detectedRedisStructuredCodec(detail: RedisMemberDetail): RedisValueCodec | null {
|
||||
if (detail.javaSerialized) return "javaserialize";
|
||||
if (detail.pickle) return "pickle";
|
||||
if (detail.msgpack && isRedisJsonContainerValue(detail.msgpack.value)) return "msgpack";
|
||||
if (detail.phpSerialized) return "phpserialize";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean {
|
||||
return !isJsonDerivedView(format) || Boolean(detail.json);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user