Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions src/commands/snapshot/sync.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import type { Command } from "commander";
import { getProfile, loadConfig } from "../../config/manager";
import type { GlobalOptions } from "../../config/types";
import { fetchCycleData } from "../../snapshot/fetch";
import { fetchCycleData, fetchSingleTestCaseData } from "../../snapshot/fetch";
import { readSnapshot, writeSnapshot } from "../../snapshot/file";
import { buildSnapshot, mergeSnapshot } from "../../snapshot/merge";
import { buildSnapshot, mergeSnapshot, reloadTestCase } from "../../snapshot/merge";
import { createClient } from "../../utils/client";
import { formatError } from "../../utils/error";
import { logger, setLoggerVerbose } from "../../utils/logger";
import { createSpinner } from "../../utils/spinner";

const TEST_CYCLE_KEY_PATTERN = /^[A-Z]+-R\d+$/;
const TEST_CASE_KEY_PATTERN = /^[A-Z]+-T\d+$/;

export function registerSyncCommand(parent: Command): void {
parent
.command("sync <target>")
.description("Sync test cycle data to a snapshot JSON file")
.option("-o, --output <path>", "Output file path (required for initial sync)")
.action(async (target: string, options: { output?: string }, command) => {
.option("-t, --test-case <key>", "Reload a single test case (e.g. CPD-T123)")
.action(async (target: string, options: { output?: string; testCase?: string }, command) => {
const spinner = createSpinner("Starting sync...");
try {
const globalOptions = command.parent?.parent?.opts() as GlobalOptions;
Expand Down Expand Up @@ -45,6 +47,35 @@ export function registerSyncCommand(parent: Command): void {
spinner.stop(
`Snapshot saved to ${options.output} (${snapshot.testCases.length} test cases)`,
);
} else if (options.testCase) {
// Single test case reload
const testCaseKey = options.testCase;
if (!TEST_CASE_KEY_PATTERN.test(testCaseKey)) {
spinner.stop();
logger.error(`Invalid test case key: ${testCaseKey}`);
process.exit(1);
}

const filePath = target;
const local = readSnapshot(filePath);
const tc = local.testCases.find((t) => t.key === testCaseKey);
if (!tc) {
spinner.stop();
logger.error(`Test case ${testCaseKey} not found in snapshot`);
process.exit(1);
}

spinner.update(`Reloading ${testCaseKey}...`);
const data = await fetchSingleTestCaseData(
client,
profile.projectKey,
testCaseKey,
tc.execution.id,
);
const updated = reloadTestCase(local, testCaseKey, data);
writeSnapshot(filePath, updated);

spinner.stop(`Reloaded: ${testCaseKey}`);
} else {
const filePath = target;
const local = readSnapshot(filePath);
Expand Down
46 changes: 34 additions & 12 deletions src/play/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,16 @@ function App({
return indices;
}, [searchQuery, state.flatListItems]);

const { pushStep, pushAllSteps, pushExecution, syncSnapshot } = useApiActions({
client,
projectKey,
filePath,
getSnapshot: () => snapshotRef.current,
updateSnapshot: actions.updateSnapshot,
setLoading: actions.setLoading,
setError: actions.setError,
});
const { pushStep, pushAllSteps, pushExecution, syncSnapshot, reloadSingleTestCase } =
useApiActions({
client,
projectKey,
filePath,
getSnapshot: () => snapshotRef.current,
updateSnapshot: actions.updateSnapshot,
setLoading: actions.setLoading,
setError: actions.setError,
});

const [pendingInput, setPendingInput] = useState<InputMode | null>(null);
const [syncPhase, setSyncPhase] = useState<SyncPhase>(null);
Expand Down Expand Up @@ -336,10 +337,31 @@ function App({
return;
}

// Sync: Shift+S
if (input === "S") {
// Reload single test case: r
if (input === "r" && selectedTestCase) {
const tc = selectedTestCase.testCase;
setSyncMessage(`Reloading ${tc.key}...`);
reloadSingleTestCase(tc.key, tc.execution.id)
.then((updated) => {
const newIndices = applyFilter(updated.testCases, filter);
setCurrentFilteredIndices(newIndices);
setSyncMessage(`Reloaded: ${tc.key}`);
if (syncMessageTimerRef.current) clearTimeout(syncMessageTimerRef.current);
syncMessageTimerRef.current = setTimeout(() => setSyncMessage(undefined), 3000);
})
.catch((error) => {
setSyncMessage(undefined);
actions.setError(
`Reload failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
});
return;
}

// Reload all (full sync): R
if (input === "R") {
setSyncPhase("confirm");
setSyncMessage("Sync now? (y/n)");
setSyncMessage("Reload all? (y/n)");
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/play/tui/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function StatusBar({
) : (
<Text dimColor>
{activePanel === "right" ? "p/f/b:step P/F/B:case" : "p/f/b:all"} o:open player e:edit
case S:sync
case r:reload R:reload all
</Text>
)}
</Box>
Expand Down
18 changes: 15 additions & 3 deletions src/play/tui/hooks/useApiActions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useRef } from "react";
import type { ZephyrV2Client } from "zephyr-api-client";
import { fetchCycleData } from "../../../snapshot/fetch";
import { fetchCycleData, fetchSingleTestCaseData } from "../../../snapshot/fetch";
import { readSnapshot, writeSnapshot } from "../../../snapshot/file";
import { type MergeResult, mergeSnapshot } from "../../../snapshot/merge";
import { type MergeResult, mergeSnapshot, reloadTestCase } from "../../../snapshot/merge";
import { pushExecutionStatus, pushStepResults } from "../../../snapshot/push";
import type { Snapshot, SnapshotStep } from "../../../snapshot/types";

Expand Down Expand Up @@ -243,5 +243,17 @@ export function useApiActions(options: UseApiActionsOptions) {
[client, projectKey, filePath, updateSnapshot],
);

return { pushStep, pushAllSteps, pushExecution, syncSnapshot };
const reloadSingleTestCase = useCallback(
async (testCaseKey: string, executionId: number | null): Promise<Snapshot> => {
const snapshot = readSnapshot(filePath);
const data = await fetchSingleTestCaseData(client, projectKey, testCaseKey, executionId);
const updated = reloadTestCase(snapshot, testCaseKey, data);
writeSnapshot(filePath, updated);
updateSnapshot(updated);
return updated;
},
[client, projectKey, filePath, updateSnapshot],
);

return { pushStep, pushAllSteps, pushExecution, syncSnapshot, reloadSingleTestCase };
}
62 changes: 62 additions & 0 deletions src/snapshot/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,65 @@ export async function fetchCycleData(
statusMap,
};
}

export interface FetchedSingleTestCaseData {
testCase: FetchedTestCase;
testCaseSteps: FetchedTestStep[];
executionSteps: FetchedExecutionStep[];
folders: Map<number, { id: number; name: string; parentId: number | null }>;
statusMap: Map<number, string>;
}

/**
* Fetch data for a single test case (for partial reload).
*/
export async function fetchSingleTestCaseData(
client: ZephyrV2Client,
projectKey: string,
testCaseKey: string,
executionId: number | null,
): Promise<FetchedSingleTestCaseData> {
const statusMap = await fetchAllStatuses(client, projectKey);

const tcResponse = await client.testcases.getTestCase(testCaseKey);
const tc = tcResponse.data;
const testCase: FetchedTestCase = {
key: tc.key,
name: tc.name,
objective: tc.objective ?? "",
precondition: tc.precondition ?? "",
estimatedTime: tc.estimatedTime ?? null,
labels: (tc.labels as string[]) ?? [],
component: (tc.component as { name?: string } | null)?.name ?? null,
customFields: (tc.customFields as Record<string, unknown>) ?? {},
folderId: tc.folder?.id ?? null,
createdOn: tc.createdOn ?? "",
};

const testCaseSteps = await fetchTestCaseSteps(client, testCaseKey);

let executionSteps: FetchedExecutionStep[] = [];
if (executionId !== null) {
executionSteps = await fetchExecutionSteps(client, executionId);
}

// Fetch folder chain
const folders = new Map<number, { id: number; name: string; parentId: number | null }>();
const foldersToFetch = testCase.folderId !== null ? [testCase.folderId] : [];
while (foldersToFetch.length > 0) {
const folderId = foldersToFetch.pop();
if (folderId === undefined || folders.has(folderId)) continue;
const folderResponse = await client.folders.getFolder(folderId);
const folder = folderResponse.data;
folders.set(folderId, {
id: folder.id,
name: folder.name,
parentId: folder.parentId ?? null,
});
if (folder.parentId && !folders.has(folder.parentId)) {
foldersToFetch.push(folder.parentId);
}
}

return { testCase, testCaseSteps, executionSteps, folders, statusMap };
}
39 changes: 38 additions & 1 deletion src/snapshot/merge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { FetchedCycleData, FetchedExecutionStep, FetchedTestStep } from "./fetch";
import type {
FetchedCycleData,
FetchedExecutionStep,
FetchedSingleTestCaseData,
FetchedTestStep,
} from "./fetch";
import { calcOriginalIndex } from "./fractional-index";
import type { Snapshot, SnapshotExecution, SnapshotStep, SnapshotTestCase } from "./types";

Expand Down Expand Up @@ -163,3 +168,35 @@ export function mergeSnapshot(local: Snapshot, data: FetchedCycleData): MergeRes

return { snapshot, added, removed, updated };
}

/**
* Reload a single test case in the snapshot from fetched data.
* Updates test case details, steps, and folder path. Preserves execution, originalIndex, and excluded.
*/
export function reloadTestCase(
snapshot: Snapshot,
testCaseKey: string,
data: FetchedSingleTestCaseData,
): Snapshot {
const newTestCases = snapshot.testCases.map((tc) => {
if (tc.key !== testCaseKey) return tc;

const { testCase, testCaseSteps, executionSteps, folders, statusMap } = data;
return {
...tc,
name: testCase.name,
objective: testCase.objective,
precondition: testCase.precondition,
estimatedTime: testCase.estimatedTime,
labels: testCase.labels,
component: testCase.component,
customFields: testCase.customFields,
folderId: testCase.folderId,
folderPath: buildFolderPath(testCase.folderId, folders),
createdAt: testCase.createdOn,
steps: buildSteps(testCaseSteps, executionSteps, statusMap),
};
});

return { ...snapshot, exportedAt: new Date().toISOString(), testCases: newTestCases };
}