diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c16692b..6fd4d47 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -37,6 +37,13 @@ import { } from "./components/ui/dialog"; import { Input } from "./components/ui/input"; import { Textarea } from "./components/ui/textarea"; +import { + isLocalSmokeFixtureConnection, + localSmokeFixtureCommandPresets, + localSmokeFixtureExpectedKeys, + localSmokeFixtureProfileId, + localSmokeFixtureSearchText, +} from "./lib/local-smoke-fixture"; import { toBackendError, type BackendErrorContext, @@ -45,10 +52,24 @@ import { toBackendErrorNotice, type OperatorNotice, } from "./lib/operator-notices"; +import { buildQaSnapshot } from "./lib/qa-snapshot"; import { buildStructuredInspectorSections, type StructuredInspectorSection, } from "./lib/structured-inspector"; +import { + appendWorkspaceKeyRecords, + filterWorkspaceKeys, + replaceWorkspaceKeyRecord, + resolveSelectedWorkspaceKey, + resolveSelectedWorkspaceKeyId, +} from "./lib/key-workspace-state"; +import { + parseDemoTtlToSeconds, + persistDemoKeyTtl, + updateDemoKeyTtl, + updateDemoStringValue, +} from "./lib/demo-workspace"; import { cn } from "./lib/utils"; declare const __APP_VERSION__: string; @@ -293,7 +314,7 @@ const initialConnections: ConnectionProfile[] = [ }, ]; -const keyData: KeyRecord[] = [ +const initialDemoKeys: KeyRecord[] = [ { id: "session:42", name: "session:42", @@ -373,11 +394,12 @@ const initialConsoleHistory: ConsoleEntry[] = [ function App() { const [connections, setConnections] = useState(initialConnections); + const [demoKeys, setDemoKeys] = useState(initialDemoKeys); const [activeConnectionId, setActiveConnectionId] = useState(initialConnections[0].id); const [activeDb, setActiveDb] = useState(databases[0]); const [search, setSearch] = useState(""); - const [selectedKeyId, setSelectedKeyId] = useState(keyData[1].id); - const [draftValue, setDraftValue] = useState(keyData[1].value); + const [selectedKeyId, setSelectedKeyId] = useState(initialDemoKeys[1].id); + const [draftValue, setDraftValue] = useState(initialDemoKeys[1].value); const [consoleInput, setConsoleInput] = useState("GET cache:homepage"); const [consoleHistory, setConsoleHistory] = useState(initialConsoleHistory); const [bootstrap, setBootstrap] = useState(fallbackBootstrap); @@ -422,18 +444,12 @@ function App() { connections.find((connection) => connection.id === activeConnectionId) ?? connections[0]; - const demoQuery = deferredSearch.trim().toLowerCase(); - const demoKeys = !demoQuery - ? keyData - : keyData.filter((item) => item.name.toLowerCase().includes(demoQuery)); - const filteredKeys = tauriAvailable ? liveKeys : demoKeys; + const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearch); + const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys; const draftErrors = validateConnectionDraft(connectionDraft, connections); const draftErrorList = Object.values(draftErrors); - const selectedKey = - filteredKeys.length === 0 - ? null - : filteredKeys.find((item) => item.id === selectedKeyId) ?? filteredKeys[0]; + const selectedKey = resolveSelectedWorkspaceKey(filteredKeys, selectedKeyId); const isEditable = tauriAvailable ? selectedValueRecord?.write_capability === "replace_string" && @@ -459,6 +475,8 @@ function App() { const ttlWriteSupported = tauriAvailable ? selectedValueRecord?.ttl_write_supported === true : selectedKey !== null; + const localSmokeFixtureActive = isLocalSmokeFixtureConnection(activeConnection); + const localSmokeFilterActive = search.trim() === localSmokeFixtureSearchText; const ttlDraftError = ttlDraft.trim() ? validateTtlDraft(ttlDraft) : null; const canSubmitTtlUpdate = selectedKey !== null && @@ -474,23 +492,25 @@ function App() { ? selectedValueRecord?.metadata.ttl.kind !== "persistent" : selectedKey?.ttl !== "persistent"); const latestConsoleEntry = consoleHistory[0] ?? null; - const qaSnapshot = [ - `app=${bootstrap.app_name}`, - `version=${appVersion}`, - `runtime=${runtimeMode}`, - `build=${buildMode}`, - `connection=${activeConnection.name}`, - `endpoint=${formatEndpoint(activeConnection.target)}`, - `database=${activeDb}`, - `selectedKey=${selectedKey?.name ?? "none"}`, - `selectedType=${selectedKey?.type ?? "none"}`, - `selectedTtl=${selectedKey?.ttl ?? "none"}`, - `browseVisible=${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`, - `latestCommand=${latestConsoleEntry?.command ?? "none"}`, - `latestCommandSource=${latestConsoleEntry?.source ?? "none"}`, - `latestCommandStatus=${latestConsoleEntry?.status ?? "none"}`, - `banner=${banner.message}`, - ].join("\n"); + const qaSnapshot = buildQaSnapshot({ + appName: bootstrap.app_name, + appVersion, + runtimeMode, + buildMode, + connectionName: activeConnection.name, + endpoint: formatEndpoint(activeConnection.target), + databaseLabel: activeDb, + smokeFixtureActive: localSmokeFixtureActive, + smokeFilter: localSmokeFilterActive ? localSmokeFixtureSearchText : search, + selectedKeyName: selectedKey?.name ?? null, + selectedKeyType: selectedKey?.type ?? null, + selectedKeyTtl: selectedKey?.ttl ?? null, + browseVisibleCount: `${filteredKeys.length}${tauriAvailable && liveHasMore ? "+" : ""}`, + latestCommand: latestConsoleEntry?.command ?? null, + latestCommandSource: latestConsoleEntry?.source ?? null, + latestCommandStatus: latestConsoleEntry?.status ?? null, + bannerMessage: banner.message, + }); useEffect(() => { if (!tauriAvailable) { @@ -569,9 +589,7 @@ function App() { setLiveKeys(nextKeys); setLiveNextCursor(result.next_cursor); setLiveHasMore(result.has_more); - setSelectedKeyId((current) => - nextKeys.some((item) => item.id === current) ? current : (nextKeys[0]?.id ?? null), - ); + setSelectedKeyId((current) => resolveSelectedWorkspaceKeyId(nextKeys, current)); setIsBrowseLoading(false); }); }) @@ -644,7 +662,7 @@ function App() { const nextRecord = toLiveKeyRecordFromValue(record); startTransition(() => { setSelectedValueRecord(record); - setLiveKeys((current) => replaceKeyRecord(current, nextRecord)); + setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord)); setDraftValue( record.data.kind === "string" && record.write_capability === "replace_string" ? formatRedisValueContent(record.data.value) @@ -723,6 +741,60 @@ function App() { }); }; + const handleUseLocalSmokeFixture = () => { + const existingFixture = connections.find(isLocalSmokeFixtureConnection); + const fixtureConnection = + existingFixture ?? + { + id: localSmokeFixtureProfileId, + name: localSmokeFixtureProfileId, + environment: "local" as const, + detail: "Fixture profile", + status: "idle" as const, + password: "", + target: { + host: "127.0.0.1", + port: 6380, + database: 0, + username: null, + tls_mode: "disabled" as const, + }, + }; + + startTransition(() => { + if (!existingFixture) { + setConnections((current) => [fixtureConnection, ...current]); + } + setActiveConnectionId(fixtureConnection.id); + setActiveDb(formatDatabaseLabel(fixtureConnection.target.database)); + setSelectedKeyId(null); + setSearch(localSmokeFixtureSearchText); + setConsoleInput(localSmokeFixtureCommandPresets[0].command); + showAccentBanner( + "Loaded the local smoke fixture profile, narrowed the browser to smoke:* keys, and primed the command tray with PING.", + ); + }); + }; + + const handleFocusLocalSmokeKeys = () => { + startTransition(() => { + setSearch(localSmokeFixtureSearchText); + setSelectedKeyId(null); + showNeutralBanner( + `Focused the browser on ${localSmokeFixtureSearchText} for local smoke verification in ${activeDb}.`, + ); + }); + }; + + const handleApplyLocalSmokeCommandPreset = (command: string) => { + startTransition(() => { + setConsoleInput(command); + showNeutralBanner( + `Loaded ${command} into the command tray so QA can execute the next safe smoke step without retyping.`, + ); + }); + }; + const handleCreateDraftConnection = () => { if (draftErrorList.length > 0) { showNeutralBanner(`Connection draft is incomplete: ${draftErrorList[0]}`); @@ -766,9 +838,12 @@ function App() { } if (!tauriAvailable) { - showNeutralBanner( - `Saved mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is not wired yet.`, - ); + startTransition(() => { + setDemoKeys((current) => updateDemoStringValue(current, selectedKey.name, draftValue)); + showNeutralBanner( + `Saved session-local mock string value for ${selectedKey.name} in ${activeDb}. Backend persistence is still unavailable in browser demo mode.`, + ); + }); return; } @@ -791,7 +866,7 @@ function App() { const nextRecord = toLiveKeyRecordFromValue(result); startTransition(() => { setSelectedValueRecord(result); - setLiveKeys((current) => replaceKeyRecord(current, nextRecord)); + setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord)); setDraftValue( result.data.kind === "string" ? formatRedisValueContent(result.data.value) @@ -839,9 +914,14 @@ function App() { } if (!tauriAvailable) { - showNeutralBanner( - `Browser dev mode keeps TTL changes in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL update.`, - ); + startTransition(() => { + setDemoKeys((current) => + updateDemoKeyTtl(current, selectedKey.name, Number.parseInt(ttlDraft, 10)), + ); + showNeutralBanner( + `Updated session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`, + ); + }); return; } @@ -893,9 +973,13 @@ function App() { } if (!tauriAvailable) { - showNeutralBanner( - `Browser dev mode keeps TTL removal in demo fallback for ${selectedKey.name}. Use desktop runtime for a live TTL change.`, - ); + startTransition(() => { + setDemoKeys((current) => persistDemoKeyTtl(current, selectedKey.name)); + setTtlDraft(""); + showNeutralBanner( + `Removed session-local mock TTL for ${selectedKey.name} in ${activeDb}. Use desktop runtime for a live TTL change.`, + ); + }); return; } @@ -967,7 +1051,7 @@ function App() { const nextRecord = toLiveKeyRecordFromValue(result); startTransition(() => { setSelectedValueRecord(result); - setLiveKeys((current) => replaceKeyRecord(current, nextRecord)); + setLiveKeys((current) => replaceWorkspaceKeyRecord(current, nextRecord)); setDraftValue( result.data.kind === "string" && result.write_capability === "replace_string" ? formatRedisValueContent(result.data.value) @@ -1020,11 +1104,15 @@ function App() { metadata, }; setSelectedValueRecord(nextRecord); - setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord))); + setLiveKeys((current) => + replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromValue(nextRecord)), + ); return; } - setLiveKeys((current) => replaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata))); + setLiveKeys((current) => + replaceWorkspaceKeyRecord(current, toLiveKeyRecordFromMetadata(metadata)), + ); }; const handleLoadMoreKeys = async () => { @@ -1048,7 +1136,7 @@ function App() { const nextKeys = result.keys.map(toLiveKeyRecordFromMetadata); startTransition(() => { - setLiveKeys((current) => appendKeyRecords(current, nextKeys)); + setLiveKeys((current) => appendWorkspaceKeyRecords(current, nextKeys)); setLiveNextCursor(result.next_cursor); setLiveHasMore(result.has_more); setIsBrowseLoading(false); @@ -1532,6 +1620,67 @@ function App() { +
+ } label="Local smoke assist" /> +
+
+
+

+ Seeded fixture path for desktop smoke runs +

+

+ Mirrors `docs/qa/local-desktop-smoke.md` and keeps the safe validation path visible inside the shell. +

+
+ + {localSmokeFixtureActive ? "active" : "inactive"} + +
+ +
+ 127.0.0.1:6380 + db0 + {localSmokeFixtureExpectedKeys.length} seeded keys +
+ +
+ + +
+ +
+ {localSmokeFixtureCommandPresets.map((preset) => ( + + ))} +
+ +
+

+ Seeded keys +

+
+ {localSmokeFixtureExpectedKeys.map((keyName) => ( + + {keyName} + + ))} +
+
+
+
+
} label="QA snapshot" />
@@ -1571,7 +1720,7 @@ function App() { {tauriAvailable ? `${filteredKeys.length}${liveHasMore ? "+" : ""} visible in ${activeDb}` - : `${filteredKeys.length} of ${keyData.length} keys in ${activeDb}`} + : `${filteredKeys.length} of ${demoKeys.length} keys in ${activeDb}`}
@@ -2147,7 +2296,7 @@ function buildMockCommandOutput(command: string, selectedKey: KeyRecord | null) } if (upper === "TTL") { - return `(integer) ${selectedKey.ttl === "persistent" ? -1 : selectedKey.ttl.replace(/[^0-9]/g, "")}`; + return `(integer) ${parseDemoTtlToSeconds(selectedKey.ttl)}`; } if (upper === "TYPE") { @@ -2290,39 +2439,6 @@ function toLiveKeyRecordFromValue(record: RedisValueRecord): KeyRecord { }; } -function replaceKeyRecord(current: KeyRecord[], next: KeyRecord) { - let replaced = false; - const updated = current.map((item) => { - if (item.id !== next.id) { - return item; - } - - replaced = true; - return { - ...item, - ...next, - }; - }); - - return replaced ? updated : [next, ...current]; -} - -function appendKeyRecords(current: KeyRecord[], next: KeyRecord[]) { - const seen = new Set(current.map((item) => item.id)); - const appended = [...current]; - - for (const item of next) { - if (seen.has(item.id)) { - continue; - } - - seen.add(item.id); - appended.push(item); - } - - return appended; -} - function buildMetadataPreview(metadata: RedisKeyMetadata) { return `${formatRedisKeyTypeLabel(metadata.key_type)} key · TTL ${formatRedisKeyTtl(metadata.ttl)}`; } diff --git a/apps/desktop/src/lib/demo-workspace.test.ts b/apps/desktop/src/lib/demo-workspace.test.ts new file mode 100644 index 0000000..3a6196f --- /dev/null +++ b/apps/desktop/src/lib/demo-workspace.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + buildDemoStringPreview, + formatDemoDurationFromMillis, + parseDemoTtlToSeconds, + persistDemoKeyTtl, + updateDemoKeyTtl, + updateDemoStringValue, + type DemoWorkspaceKey, +} from "./demo-workspace"; + +const demoKeys: DemoWorkspaceKey[] = [ + { + id: "cache:homepage", + name: "cache:homepage", + type: "string", + ttl: "48s", + preview: "serialized landing payload", + editable: true, + value: "{\"hero\":\"Redis GUI\"}", + summary: "String keys are editable in V1.", + }, +]; + +describe("demo workspace helpers", () => { + it("updates string values and refreshes the visible preview", () => { + expect( + updateDemoStringValue(demoKeys, "cache:homepage", "next value"), + ).toEqual([ + { + ...demoKeys[0], + value: "next value", + preview: "next value", + }, + ]); + }); + + it("updates TTL state for session-local demo flows", () => { + expect(updateDemoKeyTtl(demoKeys, "cache:homepage", 90_000)[0]?.ttl).toBe("1m"); + expect(persistDemoKeyTtl(demoKeys, "cache:homepage")[0]?.ttl).toBe("persistent"); + }); + + it("formats preview text and durations consistently", () => { + expect(buildDemoStringPreview("alpha beta gamma delta epsilon zeta eta theta", 16)).toBe( + "alpha beta gamm…", + ); + expect(formatDemoDurationFromMillis(750)).toBe("750ms"); + expect(formatDemoDurationFromMillis(5_000)).toBe("5s"); + expect(formatDemoDurationFromMillis(7_200_000)).toBe("2h"); + expect(parseDemoTtlToSeconds("750ms")).toBe(0); + expect(parseDemoTtlToSeconds("5s")).toBe(5); + expect(parseDemoTtlToSeconds("2h")).toBe(7200); + expect(parseDemoTtlToSeconds("persistent")).toBe(-1); + }); +}); diff --git a/apps/desktop/src/lib/demo-workspace.ts b/apps/desktop/src/lib/demo-workspace.ts new file mode 100644 index 0000000..c6876cd --- /dev/null +++ b/apps/desktop/src/lib/demo-workspace.ts @@ -0,0 +1,114 @@ +export type DemoWorkspaceKey = { + id: string; + name: string; + type: string; + ttl: string; + preview: string; + editable: boolean; + value: string; + summary: string; +}; + +export function updateDemoStringValue( + keys: DemoWorkspaceKey[], + keyName: string, + value: string, +) { + return keys.map((key) => + key.name === keyName + ? { + ...key, + value, + preview: buildDemoStringPreview(value), + } + : key, + ); +} + +export function updateDemoKeyTtl( + keys: DemoWorkspaceKey[], + keyName: string, + ttlMillis: number, +) { + return keys.map((key) => + key.name === keyName + ? { + ...key, + ttl: formatDemoDurationFromMillis(ttlMillis), + } + : key, + ); +} + +export function persistDemoKeyTtl(keys: DemoWorkspaceKey[], keyName: string) { + return keys.map((key) => + key.name === keyName + ? { + ...key, + ttl: "persistent", + } + : key, + ); +} + +export function buildDemoStringPreview(value: string, maxLength = 48) { + const collapsed = value.replace(/\s+/g, " ").trim(); + if (collapsed.length <= maxLength) { + return collapsed; + } + + return `${collapsed.slice(0, maxLength - 1)}…`; +} + +export function formatDemoDurationFromMillis(value: number) { + if (value < 1_000) { + return `${value}ms`; + } + + const totalSeconds = Math.floor(value / 1_000); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + const totalMinutes = Math.floor(totalSeconds / 60); + if (totalMinutes < 60) { + return `${totalMinutes}m`; + } + + const totalHours = Math.floor(totalMinutes / 60); + if (totalHours < 24) { + return `${totalHours}h`; + } + + const totalDays = Math.floor(totalHours / 24); + return `${totalDays}d`; +} + +export function parseDemoTtlToSeconds(value: string) { + if (value === "persistent") { + return -1; + } + + const match = value.trim().match(/^(\d+)(ms|s|m|h|d)$/); + if (!match) { + return 0; + } + + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "ms": + return Math.floor(amount / 1_000); + case "s": + return amount; + case "m": + return amount * 60; + case "h": + return amount * 60 * 60; + case "d": + return amount * 60 * 60 * 24; + default: + return 0; + } +} diff --git a/apps/desktop/src/lib/key-workspace-state.test.ts b/apps/desktop/src/lib/key-workspace-state.test.ts new file mode 100644 index 0000000..4e83589 --- /dev/null +++ b/apps/desktop/src/lib/key-workspace-state.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + appendWorkspaceKeyRecords, + filterWorkspaceKeys, + replaceWorkspaceKeyRecord, + resolveSelectedWorkspaceKey, + resolveSelectedWorkspaceKeyId, +} from "./key-workspace-state"; + +const workspaceKeys = [ + { + id: "session:42", + name: "session:42", + ttl: "13m", + preview: "hash value", + }, + { + id: "cache:homepage", + name: "cache:homepage", + ttl: "48s", + preview: "string value", + }, +]; + +describe("key workspace state helpers", () => { + it("filters keys by name using trimmed case-insensitive search", () => { + expect(filterWorkspaceKeys(workspaceKeys, " CACHE ")).toEqual([ + workspaceKeys[1], + ]); + expect(filterWorkspaceKeys(workspaceKeys, " ")).toEqual(workspaceKeys); + }); + + it("resolves the selected key and falls back to the first visible key", () => { + expect(resolveSelectedWorkspaceKey(workspaceKeys, "cache:homepage")).toEqual( + workspaceKeys[1], + ); + expect(resolveSelectedWorkspaceKey(workspaceKeys, "missing")).toEqual( + workspaceKeys[0], + ); + expect(resolveSelectedWorkspaceKey([], "missing")).toBeNull(); + }); + + it("resolves the selected key id for list refresh flows", () => { + expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "cache:homepage")).toBe( + "cache:homepage", + ); + expect(resolveSelectedWorkspaceKeyId(workspaceKeys, "missing")).toBe( + "session:42", + ); + expect(resolveSelectedWorkspaceKeyId([], "missing")).toBeNull(); + }); + + it("replaces an existing key while preserving untouched fields", () => { + expect( + replaceWorkspaceKeyRecord(workspaceKeys, { + id: "cache:homepage", + name: "cache:homepage", + ttl: "persistent", + }), + ).toEqual([ + workspaceKeys[0], + { + ...workspaceKeys[1], + ttl: "persistent", + }, + ]); + }); + + it("prepends unseen replacements and appends only new paged keys", () => { + expect( + replaceWorkspaceKeyRecord(workspaceKeys, { + id: "smoke:string", + name: "smoke:string", + ttl: "5m", + }), + ).toEqual([ + { + id: "smoke:string", + name: "smoke:string", + ttl: "5m", + }, + ...workspaceKeys, + ]); + + expect( + appendWorkspaceKeyRecords(workspaceKeys, [ + workspaceKeys[1], + { + id: "smoke:string", + name: "smoke:string", + ttl: "5m", + preview: "fixture key", + }, + ]), + ).toEqual([ + ...workspaceKeys, + { + id: "smoke:string", + name: "smoke:string", + ttl: "5m", + preview: "fixture key", + }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/key-workspace-state.ts b/apps/desktop/src/lib/key-workspace-state.ts new file mode 100644 index 0000000..bdcd8c2 --- /dev/null +++ b/apps/desktop/src/lib/key-workspace-state.ts @@ -0,0 +1,76 @@ +export type WorkspaceKeyIdentity = { + id: string; +}; + +export type WorkspaceSearchableKey = WorkspaceKeyIdentity & { + name: string; +}; + +export function filterWorkspaceKeys( + keys: T[], + search: string, +) { + const query = search.trim().toLowerCase(); + if (!query) { + return keys; + } + + return keys.filter((item) => item.name.toLowerCase().includes(query)); +} + +export function resolveSelectedWorkspaceKey( + keys: T[], + selectedKeyId: string | null, +) { + if (keys.length === 0) { + return null; + } + + return keys.find((item) => item.id === selectedKeyId) ?? keys[0]; +} + +export function resolveSelectedWorkspaceKeyId( + keys: T[], + selectedKeyId: string | null, +) { + return resolveSelectedWorkspaceKey(keys, selectedKeyId)?.id ?? null; +} + +export function replaceWorkspaceKeyRecord( + current: T[], + next: T, +) { + let replaced = false; + const updated = current.map((item) => { + if (item.id !== next.id) { + return item; + } + + replaced = true; + return { + ...item, + ...next, + }; + }); + + return replaced ? updated : [next, ...current]; +} + +export function appendWorkspaceKeyRecords( + current: T[], + next: T[], +) { + const seen = new Set(current.map((item) => item.id)); + const appended = [...current]; + + for (const item of next) { + if (seen.has(item.id)) { + continue; + } + + seen.add(item.id); + appended.push(item); + } + + return appended; +} diff --git a/apps/desktop/src/lib/local-smoke-fixture.test.ts b/apps/desktop/src/lib/local-smoke-fixture.test.ts new file mode 100644 index 0000000..5d454bb --- /dev/null +++ b/apps/desktop/src/lib/local-smoke-fixture.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + isLocalSmokeFixtureConnection, + localSmokeFixtureCommandPresets, + localSmokeFixtureExpectedKeys, + localSmokeFixtureProfileId, + localSmokeFixtureSearchText, +} from "./local-smoke-fixture"; + +describe("local smoke fixture helpers", () => { + it("keeps the expected local smoke verification targets stable", () => { + expect(localSmokeFixtureProfileId).toBe("redis-local-fixture"); + expect(localSmokeFixtureSearchText).toBe("smoke:*"); + expect(localSmokeFixtureExpectedKeys).toEqual([ + "smoke:string", + "smoke:string:ttl", + "smoke:hash", + "smoke:list", + "smoke:set", + "smoke:zset", + "smoke:stream", + ]); + }); + + it("detects the built-in local fixture profile by id or target", () => { + expect( + isLocalSmokeFixtureConnection({ + id: "redis-local-fixture", + target: { host: "10.0.0.1", port: 6379, database: 2 }, + }), + ).toBe(true); + + expect( + isLocalSmokeFixtureConnection({ + id: "custom-profile", + target: { host: "127.0.0.1", port: 6380, database: 0 }, + }), + ).toBe(true); + + expect( + isLocalSmokeFixtureConnection({ + id: "redis-local-fixture", + target: { host: "127.0.0.1", port: 6380, database: 1 }, + }), + ).toBe(true); + }); + + it("keeps fixture quick commands on a safe read-only path", () => { + expect(localSmokeFixtureCommandPresets.map((preset) => preset.command)).toEqual([ + "PING", + "GET smoke:string", + "TTL smoke:string:ttl", + ]); + }); +}); diff --git a/apps/desktop/src/lib/local-smoke-fixture.ts b/apps/desktop/src/lib/local-smoke-fixture.ts new file mode 100644 index 0000000..3fc466f --- /dev/null +++ b/apps/desktop/src/lib/local-smoke-fixture.ts @@ -0,0 +1,53 @@ +export const localSmokeFixtureProfileId = "redis-local-fixture"; +export const localSmokeFixtureSearchText = "smoke:*"; + +export const localSmokeFixtureExpectedKeys = [ + "smoke:string", + "smoke:string:ttl", + "smoke:hash", + "smoke:list", + "smoke:set", + "smoke:zset", + "smoke:stream", +] as const; + +export const localSmokeFixtureCommandPresets = [ + { + id: "ping", + label: "PING", + command: "PING", + summary: "Validate the desktop command bridge against the seeded fixture.", + }, + { + id: "get-string", + label: "GET smoke:string", + command: "GET smoke:string", + summary: "Verify the string read path against the seeded smoke key.", + }, + { + id: "ttl-string", + label: "TTL smoke:string:ttl", + command: "TTL smoke:string:ttl", + summary: "Verify the TTL path before using the inspector controls.", + }, +] as const; + +type LocalSmokeFixtureTarget = { + id: string; + target: { + host: string; + port: number; + database: number; + }; +}; + +export function isLocalSmokeFixtureConnection( + connection: LocalSmokeFixtureTarget, +) { + return ( + connection.id === localSmokeFixtureProfileId || + (connection.target.host === "127.0.0.1" && + connection.target.port === 6380 && + connection.target.database === 0) + ); +} diff --git a/apps/desktop/src/lib/qa-snapshot.test.ts b/apps/desktop/src/lib/qa-snapshot.test.ts new file mode 100644 index 0000000..b703cb0 --- /dev/null +++ b/apps/desktop/src/lib/qa-snapshot.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { buildQaSnapshot } from "./qa-snapshot"; + +describe("qa snapshot", () => { + it("serializes the current smoke context into a stable line-oriented summary", () => { + expect( + buildQaSnapshot({ + appName: "Redis GUI Foundation", + appVersion: "0.1.0", + runtimeMode: "live desktop bridge", + buildMode: "production", + connectionName: "redis-local-fixture", + endpoint: "127.0.0.1:6380", + databaseLabel: "db0", + smokeFixtureActive: true, + smokeFilter: "smoke:*", + selectedKeyName: "smoke:string", + selectedKeyType: "string", + selectedKeyTtl: "persistent", + browseVisibleCount: "7", + latestCommand: "PING", + latestCommandSource: "live", + latestCommandStatus: "ok", + bannerMessage: "Copied the current QA snapshot to the clipboard for smoke evidence.", + }), + ).toEqual( + [ + "app=Redis GUI Foundation", + "version=0.1.0", + "runtime=live desktop bridge", + "build=production", + "connection=redis-local-fixture", + "endpoint=127.0.0.1:6380", + "database=db0", + "smokeFixtureActive=yes", + "smokeFilter=smoke:*", + "selectedKey=smoke:string", + "selectedType=string", + "selectedTtl=persistent", + "browseVisible=7", + "latestCommand=PING", + "latestCommandSource=live", + "latestCommandStatus=ok", + "banner=Copied the current QA snapshot to the clipboard for smoke evidence.", + ].join("\n"), + ); + }); + + it("normalizes empty or missing values so smoke logs stay readable", () => { + expect( + buildQaSnapshot({ + appName: "Redis GUI Foundation", + appVersion: "0.1.0", + runtimeMode: "browser demo fallback", + buildMode: "dev", + connectionName: "redis-dev-eu-1", + endpoint: "127.0.0.1:6379", + databaseLabel: "db1", + smokeFixtureActive: false, + smokeFilter: " ", + selectedKeyName: null, + selectedKeyType: null, + selectedKeyTtl: null, + browseVisibleCount: "0", + latestCommand: null, + latestCommandSource: null, + latestCommandStatus: null, + bannerMessage: "No key matches the current pattern.", + }), + ).toContain("smokeFilter=none"); + }); +}); diff --git a/apps/desktop/src/lib/qa-snapshot.ts b/apps/desktop/src/lib/qa-snapshot.ts new file mode 100644 index 0000000..5b68fa6 --- /dev/null +++ b/apps/desktop/src/lib/qa-snapshot.ts @@ -0,0 +1,41 @@ +export type QaSnapshotInput = { + appName: string; + appVersion: string; + runtimeMode: string; + buildMode: string; + connectionName: string; + endpoint: string; + databaseLabel: string; + smokeFixtureActive: boolean; + smokeFilter: string; + selectedKeyName: string | null; + selectedKeyType: string | null; + selectedKeyTtl: string | null; + browseVisibleCount: string; + latestCommand: string | null; + latestCommandSource: string | null; + latestCommandStatus: string | null; + bannerMessage: string; +}; + +export function buildQaSnapshot(input: QaSnapshotInput) { + return [ + `app=${input.appName}`, + `version=${input.appVersion}`, + `runtime=${input.runtimeMode}`, + `build=${input.buildMode}`, + `connection=${input.connectionName}`, + `endpoint=${input.endpoint}`, + `database=${input.databaseLabel}`, + `smokeFixtureActive=${input.smokeFixtureActive ? "yes" : "no"}`, + `smokeFilter=${input.smokeFilter.trim() || "none"}`, + `selectedKey=${input.selectedKeyName ?? "none"}`, + `selectedType=${input.selectedKeyType ?? "none"}`, + `selectedTtl=${input.selectedKeyTtl ?? "none"}`, + `browseVisible=${input.browseVisibleCount}`, + `latestCommand=${input.latestCommand ?? "none"}`, + `latestCommandSource=${input.latestCommandSource ?? "none"}`, + `latestCommandStatus=${input.latestCommandStatus ?? "none"}`, + `banner=${input.bannerMessage}`, + ].join("\n"); +} diff --git a/plans/2026-03-31-hac-26-workspace-state-foundation.md b/plans/2026-03-31-hac-26-workspace-state-foundation.md new file mode 100644 index 0000000..bf22261 --- /dev/null +++ b/plans/2026-03-31-hac-26-workspace-state-foundation.md @@ -0,0 +1,36 @@ +# HAC-26 Workspace State Foundation + +Date: 2026-03-31 UTC +Owner: Senior Frontend Engineer +Issue: HAC-26 +Scope: `apps/desktop` frontend foundation only + +## Context + +- `apps/desktop/src/App.tsx` already owns the visible desktop-v1 shell, but it still contains repeated inline logic for key filtering, selected-key fallback, live key replacement, and paged append handling. +- These state transitions are part of the browsing and inspector foundation, not one-off UI copy, and upcoming desktop-v1 slices will need them again. +- The PM comment on `HAC-26` asked for the highest reusable foundation return, not a new visible feature. + +## Decision + +- Do not expand product scope. +- Extract reusable key-workspace state helpers from `App.tsx` into `src/lib`. +- Cover the extracted state transitions with focused Vitest cases so future desktop-v1 changes can reuse the same behavior safely. + +## Planned Implementation + +1. Create a shared frontend utility for: + - filtering visible keys by search text + - resolving selected key fallback when visible results change + - replacing a key record after live metadata/value refresh + - appending paged browse results without duplicate rows +2. Update `App.tsx` to consume the shared utility instead of inline logic. +3. Add Vitest coverage for zero-result fallback, selection resolution, replacement merge behavior, and append deduplication. +4. Re-run `pnpm --filter @redis-gui/desktop test` and `pnpm run desktop:build`. + +## Acceptance + +- `App.tsx` no longer owns the reusable key-workspace list and selection transitions inline. +- The extracted helper is covered by focused tests. +- Existing desktop frontend tests and production build remain green. +- No backend contract or visible feature scope changes are introduced.