388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|