Files
t8y2--dbx/packages/app-tests/redisSyntaxDiagnostics.test.ts
2026-07-13 13:09:14 +08:00

147 lines
5.7 KiB
TypeScript
Vendored

import assert from "node:assert/strict";
import { test } from "vitest";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics, tokenizeRedisLine } from "../../apps/desktop/src/lib/redis/redisSyntaxDiagnostics.ts";
function messages(source: string): string[] {
return buildRedisSyntaxDiagnostics(source).map((d) => d.message);
}
test("reports no diagnostics for read-only valid commands", () => {
assert.deepEqual(messages("GET foo"), []);
assert.deepEqual(messages("HGETALL k"), []);
assert.deepEqual(messages("SCAN 0"), []);
assert.deepEqual(messages("ZRANDMEMBER leaderboard 2 WITHSCORES"), []);
});
test("recognizes set store commands as writes", () => {
const diags = buildRedisSyntaxDiagnostics("SDIFFSTORE destination source-a source-b");
assert.equal(diags.length, 1);
assert.equal(diags[0].severity, "warning");
assert.doesNotMatch(diags[0].message, /Unknown command/);
});
test("flags unknown command name", () => {
const diags = buildRedisSyntaxDiagnostics("GETT foo");
assert.equal(diags.length, 1);
assert.equal(diags[0].severity, "error");
assert.match(diags[0].message, /Unknown command 'GETT'/);
// span covers the command-name token on line 1
assert.equal(diags[0].span.start_line, 1);
assert.equal(diags[0].span.start_column, 1);
});
test("flags wrong arity (too few arguments)", () => {
const diags = buildRedisSyntaxDiagnostics("GET");
assert.equal(diags.length, 1);
assert.match(diags[0].message, /Wrong number of arguments for 'GET'/);
assert.match(diags[0].message, /expected exactly 1 argument/);
});
test("flags wrong arity (too many arguments)", () => {
// GET arity is 2 (exact). Extra args → error.
const diags = buildRedisSyntaxDiagnostics("GET a b");
assert.equal(diags.length, 1);
assert.match(diags[0].message, /Wrong number of arguments for 'GET'/);
});
test("respects variable arity (negative arity is a minimum)", () => {
// MSET arity -3 → at least 2 args; 2 args (3 tokens) is valid.
const ok3 = buildRedisSyntaxDiagnostics("MSET k1 v1");
assert.equal(ok3.length, 0);
const ok4 = buildRedisSyntaxDiagnostics("MSET k1 v1 k2 v2");
assert.equal(ok4.length, 0);
// Too few → arity error takes precedence.
const diags = buildRedisSyntaxDiagnostics("MSET k1");
assert.equal(diags.length, 1);
assert.match(diags[0].message, /Wrong number of arguments for 'MSET'/);
});
test("flags unclosed quote and spans to end of line", () => {
const diags = buildRedisSyntaxDiagnostics('SET "abc b');
assert.equal(diags.length, 1);
assert.equal(diags[0].severity, "error");
assert.match(diags[0].message, /Unclosed quote/);
assert.equal(diags[0].span.start_column, 5); // quote starts at column 5
});
test("highlights destructive commands as warning", () => {
const diags = buildRedisSyntaxDiagnostics("DEL x");
assert.equal(diags.length, 1);
assert.equal(diags[0].severity, "warning");
assert.match(diags[0].message, /Dangerous command 'DEL'/);
});
test("highlights flushall as blocked error but flushdb as confirm warning", () => {
// Aligned with the execution safety classification: FLUSHALL=blocked, FLUSHDB=confirm.
const all = buildRedisSyntaxDiagnostics("FLUSHALL");
assert.equal(all.length, 1);
assert.equal(all[0].severity, "error");
assert.match(all[0].message, /Blocked command 'FLUSHALL'/);
const db = buildRedisSyntaxDiagnostics("FLUSHDB");
assert.equal(db.length, 1);
assert.equal(db[0].severity, "warning");
assert.match(db[0].message, /Dangerous command 'FLUSHDB'/);
});
test("subcommands resolve via MAIN SUB key", () => {
// XGROUP CREATE stream group id MKSTREAM → 6 tokens, satisfies arity -6.
const diags = buildRedisSyntaxDiagnostics("XGROUP CREATE stream group $ MKSTREAM");
assert.equal(diags.length, 0);
});
test("treats command names case-insensitively", () => {
assert.deepEqual(messages("get foo"), []);
const setDiag = buildRedisSyntaxDiagnostics("Set a b");
assert.equal(setDiag.length, 0);
const diags = buildRedisSyntaxDiagnostics("flushall");
assert.match(diags[0].message, /Blocked command 'flushall'/);
});
test("handles multi-line input with per-line diagnostics", () => {
const source = "GET ok\nFLUSHALL\nBADCMD x";
const diags = buildRedisSyntaxDiagnostics(source);
assert.equal(diags.length, 2);
const lines = diags.map((d) => d.span.start_line).sort((a, b) => a - b);
assert.deepEqual(lines, [2, 3]);
assert.match(diags.find((d) => d.span.start_line === 2)!.message, /Blocked/);
assert.match(diags.find((d) => d.span.start_line === 3)!.message, /Unknown command/);
});
test("ignores blank and comment lines", () => {
assert.deepEqual(messages(""), []);
assert.deepEqual(messages("\n\n"), []);
assert.deepEqual(messages("# a note\nGET foo"), []);
assert.deepEqual(messages("-- note\nGET foo"), []);
});
test("strips trailing semicolon terminator", () => {
assert.deepEqual(messages("GET foo;"), []);
});
test("shouldRunRedisDiagnostics waits while typing the command name", () => {
// cursor at end of a bare partial token (no whitespace yet) → wait
assert.equal(shouldRunRedisDiagnostics("GETT", 4), false);
// once a space exists after the command name → run
assert.equal(shouldRunRedisDiagnostics("GETT foo", 8), true);
assert.equal(shouldRunRedisDiagnostics("", 0), false);
});
test("tokenizeRedisLine preserves quoted values with spaces", () => {
const { argv, unclosedQuote } = tokenizeRedisLine('SET mykey "hello world"');
assert.equal(unclosedQuote, false);
assert.deepEqual(
argv.map((t) => t.value),
["SET", "mykey", "hello world"],
);
assert.equal(argv[2]!.startColumn, 11);
});
test("tokenizeRedisLine handles backslash escapes", () => {
const { argv } = tokenizeRedisLine("SET k a\\ b");
assert.deepEqual(
argv.map((t) => t.value),
["SET", "k", "a b"],
);
});