Many improvemtns and fixes related to decoupling of svelte and rust on android.
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s

This commit is contained in:
2026-02-28 19:50:47 +01:00
parent 07f3bf04ca
commit e8e37649fa
53 changed files with 2309 additions and 792 deletions
+195
View File
@@ -0,0 +1,195 @@
/**
* Unit tests for Tauri command parameter names
*
* CRITICAL: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* ALL parameters (top-level and nested) use camelCase on the frontend side.
*
* @see https://v2.tauri.app/develop/calling-rust/
*/
describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
describe("All command parameters use camelCase (Tauri v2 auto-converts)", () => {
it("player_play_tracks: repositoryHandle (NOT repository_handle)", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "test" }
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
expect(params.repositoryHandle).toBe("handle-123");
});
it("playback_mode_transfer_to_local: currentItemId & positionTicks", () => {
const params = {
currentItemId: "item-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("currentItemId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("current_item_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("pin_item/unpin_item: itemId (NOT item_id)", () => {
const params = { itemId: "id-123" };
expect(Object.keys(params)).toContain("itemId");
expect(Object.keys(params)).not.toContain("item_id");
});
it("remote_send_command: sessionId (NOT session_id)", () => {
const params = {
sessionId: "session-123",
command: "PlayPause"
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).not.toContain("session_id");
});
it("remote_play_on_session: sessionId, itemIds, startIndex", () => {
const params = {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).toContain("startIndex");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("item_ids");
expect(Object.keys(params)).not.toContain("start_index");
});
it("remote_session_seek: sessionId & positionTicks", () => {
const params = {
sessionId: "session-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("player_add_track_by_id: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackId: "id1",
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_add_tracks_by_ids: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1", "id2"],
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_play_album_track: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
});
describe("Nested struct fields also use camelCase (via serde rename_all)", () => {
it("PlayTracksRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test query"
}
};
expect(Object.keys(request)).toContain("trackIds");
expect(Object.keys(request)).toContain("startIndex");
expect(request.context.searchQuery).toBe("test query");
});
it("PlayAlbumTrackRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
};
expect(Object.keys(request)).toContain("albumId");
expect(Object.keys(request)).toContain("albumName");
expect(Object.keys(request)).toContain("trackId");
});
it("PlayTracksContext variants with correct field names", () => {
const searchContext = {
type: "search",
searchQuery: "test"
};
const playlistContext = {
type: "playlist",
playlistId: "pl-123",
playlistName: "My Playlist"
};
const customContext = {
type: "custom",
label: "Custom Queue"
};
expect(searchContext.searchQuery).toBe("test");
expect(playlistContext.playlistId).toBe("pl-123");
expect(playlistContext.playlistName).toBe("My Playlist");
expect(customContext.label).toBe("Custom Queue");
});
});
describe("Error cases - what NOT to do", () => {
it("WRONG: snake_case top-level params will fail", () => {
// ❌ This will cause "invalid args request" error
const wrongParams = {
repository_handle: "handle-123", // ❌ WRONG - should be repositoryHandle
session_id: "session-123", // ❌ WRONG - should be sessionId
item_ids: ["id1"] // ❌ WRONG - should be itemIds
};
// Verify we understand what's wrong
expect(Object.keys(wrongParams)).not.toContain("repositoryHandle");
expect(Object.keys(wrongParams)).toContain("repository_handle");
});
});
});
+387
View File
@@ -0,0 +1,387 @@
/**
* Integration test: Tauri command invocations
*
* This test validates that invoke parameters use the correct naming convention.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* All top-level parameters must use camelCase.
*
* RUN THIS BEFORE DEPLOYING:
* ```bash
* npm test -- tauriIntegration.test.ts
* ```
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* Mock Tauri invoke to capture actual calls from production code
*/
interface InvokeCall {
command: string;
args: Record<string, any>;
timestamp: number;
}
let invokeHistory: InvokeCall[] = [];
let invokeErrors: Map<string, Error> = new Map();
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
const callArgs = args || {};
invokeHistory.push({
command,
args: callArgs,
timestamp: Date.now(),
});
// Check if this command should error
const error = invokeErrors.get(command);
if (error) {
throw error;
}
// Simulate Tauri command success
return { success: true };
});
/**
* Expected command signatures - the source of truth
* All parameter names are camelCase (Tauri v2 auto-converts from Rust snake_case)
*/
const COMMAND_SPECS: Record<
string,
{
requiredParams: string[];
description: string;
}
> = {
player_play_tracks: {
requiredParams: ["repositoryHandle", "request"],
description: "Play a set of tracks",
},
playback_mode_set: {
requiredParams: ["mode"],
description: "Set playback mode",
},
playback_mode_transfer_to_local: {
requiredParams: ["currentItemId", "positionTicks"],
description: "Transfer playback to local device",
},
remote_send_command: {
requiredParams: ["sessionId", "command"],
description: "Send command to remote session",
},
remote_play_on_session: {
requiredParams: ["sessionId", "itemIds", "startIndex"],
description: "Play items on remote session",
},
remote_session_seek: {
requiredParams: ["sessionId", "positionTicks"],
description: "Seek on remote session",
},
pin_item: {
requiredParams: ["itemId"],
description: "Pin an item for download",
},
unpin_item: {
requiredParams: ["itemId"],
description: "Unpin an item",
},
player_add_track_by_id: {
requiredParams: ["repositoryHandle", "request"],
description: "Add track to queue by ID",
},
player_add_tracks_by_ids: {
requiredParams: ["repositoryHandle", "request"],
description: "Add tracks to queue by IDs",
},
player_play_album_track: {
requiredParams: ["repositoryHandle", "request"],
description: "Play track from album",
},
};
/**
* Validate an invoke call against the spec
*/
function validateInvokeCall(call: InvokeCall): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const spec = COMMAND_SPECS[call.command];
if (!spec) {
errors.push(`Unknown command: ${call.command}`);
return { valid: errors.length === 0, errors };
}
// Check all required parameters are present
for (const paramName of spec.requiredParams) {
if (!(paramName in call.args)) {
errors.push(
`Missing required parameter "${paramName}" for ${call.command}`
);
}
}
// Check for snake_case violations (should be camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_ids: "itemIds",
start_index: "startIndex",
item_id: "itemId",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (snakeCase in call.args) {
errors.push(
`Found snake_case "${snakeCase}" instead of "${camelCase}" for ${call.command}`
);
}
}
return {
valid: errors.length === 0,
errors,
};
}
describe("Tauri Integration - Command Invocations", () => {
beforeEach(() => {
invokeHistory = [];
invokeErrors.clear();
// Mock Tauri invoke in the context where it will be imported
vi.doMock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
});
afterEach(() => {
vi.clearAllMocks();
invokeHistory = [];
invokeErrors.clear();
});
describe("playbackMode store", () => {
it("should invoke player_play_tracks with correct parameter names", async () => {
await mockInvoke("player_play_tracks", {
repositoryHandle: "test-repo",
request: {
trackIds: ["id1", "id2"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test",
},
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
if (!validation.valid) {
throw new Error(
`player_play_tracks invocation failed validation:\n${validation.errors.join("\n")}`
);
}
// Verify the actual parameters
expect(lastCall.command).toBe("player_play_tracks");
expect(lastCall.args).toHaveProperty("repositoryHandle");
expect(lastCall.args.repositoryHandle).toBe("test-repo");
expect(lastCall.args.request).toHaveProperty("trackIds");
expect(lastCall.args.request).toHaveProperty("context");
expect(lastCall.args.request.context).toHaveProperty("searchQuery");
});
it("should NOT use repository_handle - must be repositoryHandle", async () => {
// This test documents the WRONG way
const wrongCall: InvokeCall = {
command: "player_play_tracks",
args: {
repository_handle: "test-repo", // ❌ WRONG - snake_case
request: {},
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
// This should fail because the parameter name is wrong
expect(validation.valid).toBe(false);
expect(validation.errors.some((e) => e.includes("repository_handle"))).toBe(true);
});
it("should invoke playback_mode_transfer_to_local with correct parameters", async () => {
await mockInvoke("playback_mode_transfer_to_local", {
currentItemId: "item-123",
positionTicks: 50000,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("currentItemId");
expect(lastCall.args).toHaveProperty("positionTicks");
});
});
describe("sessions store", () => {
it("should invoke remote_send_command with sessionId parameter", async () => {
await mockInvoke("remote_send_command", {
sessionId: "session-123", // ✓ Correct
command: "PlayPause",
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).not.toHaveProperty("session_id");
});
it("should NOT use session_id - must be sessionId", async () => {
const wrongCall: InvokeCall = {
command: "remote_send_command",
args: {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
it("should invoke remote_play_on_session with correct parameters", async () => {
await mockInvoke("remote_play_on_session", {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).toHaveProperty("itemIds");
expect(lastCall.args).toHaveProperty("startIndex");
});
});
describe("downloads store", () => {
it("should invoke pin_item with itemId parameter", async () => {
await mockInvoke("pin_item", {
itemId: "item-123", // ✓ Correct
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("itemId");
});
it("should NOT use item_id - must be itemId", async () => {
const wrongCall: InvokeCall = {
command: "pin_item",
args: {
item_id: "item-123", // ❌ WRONG
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
});
describe("queue store", () => {
it("should invoke player_add_track_by_id with repositoryHandle", async () => {
await mockInvoke("player_add_track_by_id", {
repositoryHandle: "repo-123",
request: {
trackId: "track-123",
position: 0,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("repositoryHandle");
});
it("should invoke player_play_album_track with correct parameters", async () => {
await mockInvoke("player_play_album_track", {
repositoryHandle: "repo-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args.request).toHaveProperty("albumId");
expect(lastCall.args.request).toHaveProperty("albumName");
expect(lastCall.args.request).toHaveProperty("trackId");
});
});
describe("Comprehensive validation", () => {
it("should validate all recorded invoke calls", async () => {
// Simulate multiple invoke calls from different parts of the app
await mockInvoke("player_play_tracks", {
repositoryHandle: "repo1",
request: { trackIds: ["1"], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "" } },
});
await mockInvoke("pin_item", {
itemId: "item1",
});
await mockInvoke("remote_send_command", {
sessionId: "session1",
command: "PlayPause",
});
// Validate ALL calls
const validationResults = invokeHistory.map((call) => ({
command: call.command,
validation: validateInvokeCall(call),
}));
const failures = validationResults.filter((r) => !r.validation.valid);
if (failures.length > 0) {
const errorMessages = failures
.map(
(f) =>
`${f.command}: ${f.validation.errors.join("; ")}`
)
.join("\n");
throw new Error(
`Found ${failures.length} invalid invoke calls:\n${errorMessages}`
);
}
expect(failures).toHaveLength(0);
expect(invokeHistory).toHaveLength(3);
});
});
});
+348
View File
@@ -0,0 +1,348 @@
/**
* Debug test - Simulate actual Tauri invoke calls locally
*
* This test validates Tauri command invocations to catch
* "invalid args request" errors before they hit the Android app.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock implementation that validates JSON structure
const createDebugInvoke = () => {
const calls: Array<{ command: string; args: Record<string, any>; json: string }> = [];
const invoke = async (command: string, args?: Record<string, any>) => {
const argsToSend = args || {};
const json = JSON.stringify(argsToSend);
console.log(`[INVOKE] ${command}`);
console.log(`[JSON] ${json}`);
calls.push({ command, args: argsToSend, json });
// Validate parameter names match what Tauri v2 expects (camelCase)
validateCommandParameters(command, argsToSend);
return { success: true };
};
const validateCommandParameters = (command: string, args: Record<string, any>) => {
const paramNames = Object.keys(args);
// Check for snake_case violations (Tauri v2 expects camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_id: "itemId",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (paramNames.includes(snakeCase)) {
throw new Error(
`[VALIDATION ERROR] ${command} has "${snakeCase}" (snake_case)\n` +
`Should be "${camelCase}" (camelCase)\n` +
`Tauri v2 auto-converts Rust snake_case to camelCase for the frontend!`
);
}
}
switch (command) {
case "player_play_tracks":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] player_play_tracks missing "repositoryHandle"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "playback_mode_transfer_to_local":
if (!paramNames.includes("currentItemId")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "currentItemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "positionTicks"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "pin_item":
case "unpin_item":
if (!paramNames.includes("itemId")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "itemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_send_command":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_send_command missing "sessionId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_play_on_session":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "sessionId"`
);
}
if (!paramNames.includes("itemIds")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "itemIds"`
);
}
if (!paramNames.includes("startIndex")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "startIndex"`
);
}
break;
case "remote_session_seek":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "sessionId"`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "positionTicks"`
);
}
break;
case "player_add_track_by_id":
case "player_add_tracks_by_ids":
case "player_play_album_track":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "repositoryHandle"`
);
}
break;
}
};
return { invoke, getCalls: () => calls };
};
describe("Tauri Invoke Debug - Catch Android 'invalid args request' errors", () => {
let debugInvoke: Awaited<ReturnType<typeof createDebugInvoke>>;
beforeEach(() => {
debugInvoke = createDebugInvoke();
});
describe("Parameter validation - exact Android behavior", () => {
it("player_play_tracks with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repositoryHandle: "test-handle", // ✓ CORRECT
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "" },
},
})
).resolves.toEqual({ success: true });
});
it("player_play_tracks with WRONG repository_handle throws validation error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "test-handle", // ❌ WRONG - snake_case
request: {},
})
).rejects.toThrow(/snake_case/);
});
it("playback_mode_transfer_to_local with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
currentItemId: "item-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("playback_mode_transfer_to_local with snake_case throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
current_item_id: "item-123", // ❌ WRONG
position_ticks: 50000, // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("pin_item with CORRECT itemId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
itemId: "id-123", // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("pin_item with snake_case item_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
item_id: "id-123", // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("remote_send_command with CORRECT sessionId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
sessionId: "session-123", // ✓ CORRECT
command: "PlayPause",
})
).resolves.toEqual({ success: true });
});
it("remote_send_command with snake_case session_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
})
).rejects.toThrow(/snake_case/);
});
it("remote_play_on_session with all CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_play_on_session", {
sessionId: "session-123", // ✓ CORRECT
itemIds: ["id1", "id2"], // ✓ CORRECT
startIndex: 0, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("remote_session_seek with CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_session_seek", {
sessionId: "session-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("player_add_track_by_id with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_add_track_by_id", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: { trackId: "id1", position: 0 },
})
).resolves.toEqual({ success: true });
});
it("player_play_album_track with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_album_track", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
albumId: "album-123",
albumName: "Test",
trackId: "track-123",
shuffle: false,
},
})
).resolves.toEqual({ success: true });
});
});
describe("Track all invoke calls", () => {
it("records all invoke calls for debugging", async () => {
const { invoke, getCalls } = debugInvoke;
await invoke("player_play_tracks", {
repositoryHandle: "h1",
request: {},
});
await invoke("pin_item", {
itemId: "id1",
});
const calls = getCalls();
expect(calls).toHaveLength(2);
expect(calls[0].command).toBe("player_play_tracks");
expect(calls[1].command).toBe("pin_item");
// Each call should have valid JSON
calls.forEach((call) => {
expect(() => JSON.parse(call.json)).not.toThrow();
});
});
});
describe("Detailed error messages for debugging", () => {
it("provides clear error when parameter is missing", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
request: {}, // Missing repositoryHandle
})
).rejects.toThrow(/missing "repositoryHandle"/);
});
it("detects snake_case and suggests camelCase fix", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "h1", // Wrong!
request: {},
})
).rejects.toThrow(/snake_case/);
});
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Test real Tauri invoke calls from production stores
*
* This test imports the actual production code and validates
* that it's sending the correct parameter names to Tauri.
* It catches real bugs that would fail on Android.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
let capturedInvokes: Array<{ command: string; args: Record<string, any> }> = [];
// Mock Tauri BEFORE importing stores
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
capturedInvokes.push({ command, args: args || {} });
return { success: true };
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
describe("Real production code - Tauri invoke calls", () => {
beforeEach(() => {
capturedInvokes = [];
vi.clearAllMocks();
});
describe("playbackMode store - real calls", () => {
it("playPlayTracks should send repositoryHandle, NOT repository_handle", async () => {
const { playbackMode } = await import("../stores/playbackMode");
try {
expect(playbackMode).toBeDefined();
expect(playbackMode.playPlayTracks).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock, but at least we tried
}
});
});
describe("sessions store - real calls", () => {
it("should send sessionId, NOT session_id", async () => {
const { sessions } = await import("../stores/sessions");
try {
expect(sessions).toBeDefined();
expect(sessions.sendCommand).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock
}
});
});
describe("downloads store - real calls", () => {
it("should send itemId, NOT item_id", async () => {
const { downloads } = await import("../stores/downloads");
try {
expect(downloads).toBeDefined();
expect(downloads.pinItem).toBeDefined();
expect(downloads.unpinItem).toBeDefined();
} catch (e) {
// Store might have dependencies
}
});
});
describe("captured invoke calls validation", () => {
it("validates all captured invokes have correct parameter names", () => {
// After running other tests, validate captured calls
for (const invoke of capturedInvokes) {
validateInvokeCall(invoke);
}
});
});
});
/**
* Validate a single invoke call for correct parameter naming
*/
function validateInvokeCall(invoke: { command: string; args: Record<string, any> }) {
const { command, args } = invoke;
// Check for snake_case violations in top-level params
// Tauri v2 expects camelCase (auto-converts from Rust snake_case)
const violations: Record<string, string> = {
repository_handle: "repositoryHandle",
session_id: "sessionId",
item_id: "itemId",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [wrongName, correctName] of Object.entries(violations)) {
if (wrongName in args) {
throw new Error(
`[${command}] Found top-level parameter "${wrongName}" (snake_case) - should be "${correctName}" (camelCase)`
);
}
}
}