codex specify

This commit is contained in:
2026-02-25 21:19:48 +03:00
parent b7d1ee2b71
commit 5ec1254336
40 changed files with 3535 additions and 238 deletions

View File

@@ -21,6 +21,9 @@ vi.mock('../../lib/i18n', () => ({
fn({
tasks: {
loading: 'Loading...'
},
common: {
retry: 'Retry'
}
});
return () => { };

View File

@@ -9,6 +9,7 @@
<script>
import { t } from "../../lib/i18n";
import { requestApi } from "../../lib/api";
import { addToast } from "../../lib/toasts.js";
/** @type {Array} */
export let providers = [];
@@ -28,6 +29,7 @@
let testStatus = { type: "", message: "" };
let isTesting = false;
let togglingProviderIds = new Set();
function isMultimodalModel(modelName) {
const token = (modelName || "").toLowerCase();
@@ -124,19 +126,32 @@
resetForm();
onSave();
} catch (err) {
alert(`Error: ${err.message}`);
addToast(err.message, "error");
}
}
async function toggleActive(provider) {
if (togglingProviderIds.has(provider.id)) return;
const previousState = Boolean(provider.is_active);
provider.is_active = !previousState;
providers = [...providers];
togglingProviderIds.add(provider.id);
togglingProviderIds = new Set(togglingProviderIds);
try {
await requestApi(`/llm/providers/${provider.id}`, "PUT", {
...provider,
is_active: !provider.is_active,
is_active: provider.is_active,
});
onSave();
} catch (err) {
console.error("Failed to toggle status", err);
provider.is_active = previousState;
providers = [...providers];
addToast(err.message, "error");
} finally {
togglingProviderIds.delete(provider.id);
togglingProviderIds = new Set(togglingProviderIds);
onSave();
}
}
</script>
@@ -333,8 +348,13 @@
type="button"
class={`text-sm ${provider.is_active ? "text-orange-600" : "text-green-600"} hover:underline`}
on:click|preventDefault|stopPropagation={() => toggleActive(provider)}
disabled={togglingProviderIds.has(provider.id)}
>
{provider.is_active ? "Deactivate" : "Activate"}
{#if togglingProviderIds.has(provider.id)}
...
{:else}
{provider.is_active ? "Deactivate" : "Activate"}
{/if}
</button>
</div>
</div>

View File

@@ -7,7 +7,39 @@
import { addToast } from './toasts.js';
import { PUBLIC_WS_URL } from '$env/static/public';
const API_BASE_URL = '/api';
const API_BASE_URL = '/api';
// [DEF:buildApiError:Function]
// @PURPOSE: Creates a normalized Error object for failed API responses.
// @PRE: response is a failed fetch Response object.
// @POST: Returned error contains message and status fields.
async function buildApiError(response) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
const error = new Error(message);
error.status = response.status;
return error;
}
// [/DEF:buildApiError:Function]
// [DEF:notifyApiError:Function]
// @PURPOSE: Shows toast for API errors with explicit handling of critical statuses.
// @PRE: error is an Error instance.
// @POST: User gets visible toast feedback for request failure.
function notifyApiError(error) {
if (error?.status === 401) {
addToast(`401 Unauthorized: ${error.message}`, 'error');
return;
}
if (error?.status >= 500) {
addToast(`Server error (${error.status}): ${error.message}`, 'error');
return;
}
addToast(error.message, 'error');
}
// [/DEF:notifyApiError:Function]
// [DEF:getWsUrl:Function]
// @PURPOSE: Returns the WebSocket URL for a specific task, with fallback logic.
@@ -54,22 +86,18 @@ async function fetchApi(endpoint) {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders()
});
console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
addToast(error.message, 'error');
throw error;
}
}
console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw await buildApiError(response);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
notifyApiError(error);
throw error;
}
}
// [/DEF:fetchApi:Function]
// [DEF:postApi:Function]
@@ -87,22 +115,18 @@ async function postApi(endpoint, body) {
headers: getAuthHeaders(),
body: JSON.stringify(body),
});
console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
addToast(error.message, 'error');
throw error;
}
}
console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw await buildApiError(response);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
notifyApiError(error);
throw error;
}
}
// [/DEF:postApi:Function]
// [DEF:requestApi:Function]
@@ -119,27 +143,24 @@ async function requestApi(endpoint, method = 'GET', body = null) {
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${message}'}}`);
throw new Error(message);
}
if (response.status === 204) {
console.log('[api.requestApi][Action] 204 No Content received');
return null;
}
return await response.json();
} catch (error) {
console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error);
addToast(error.message, 'error');
throw error;
}
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const error = await buildApiError(response);
console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${error.message}'}}`);
throw error;
}
if (response.status === 204) {
console.log('[api.requestApi][Action] 204 No Content received');
return null;
}
return await response.json();
} catch (error) {
console.error(`[api.requestApi][Coherence:Failed] Error ${method} to ${endpoint}:`, error);
notifyApiError(error);
throw error;
}
}
// [/DEF:requestApi:Function]
// [DEF:api:Data]

View File

@@ -40,6 +40,7 @@
getAssistantHistory,
getAssistantConversations,
} from "$lib/api/assistant.js";
import { gitService } from "../../../services/gitService.js";
const HISTORY_PAGE_SIZE = 30;
const CONVERSATIONS_PAGE_SIZE = 20;
@@ -385,6 +386,33 @@
return;
}
if (action.type === "open_route" && action.target) {
goto(action.target);
return;
}
if (action.type === "open_diff" && action.target) {
const dashboardId = Number(action.target);
if (!Number.isFinite(dashboardId) || dashboardId <= 0) {
throw new Error("Invalid dashboard id for diff");
}
const diffPayload = await gitService.getDiff(dashboardId);
const diffText =
typeof diffPayload === "string"
? diffPayload
: diffPayload?.diff || JSON.stringify(diffPayload, null, 2);
appendAssistantResponse({
response_id: `diff-${Date.now()}`,
text: diffText
? `Diff для дашборда ${dashboardId}:\n\n${diffText}`
: `Diff для дашборда ${dashboardId} пуст.`,
state: "success",
created_at: new Date().toISOString(),
actions: [],
});
return;
}
if (action.type === "confirm" && message.confirmation_id) {
// Hide buttons immediately to prevent repeated clicks
messages = messages.map((m) =>

View File

@@ -1,50 +1,127 @@
// [DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]
// @TIER: STANDARD
// @SEMANTICS: assistant, confirmation, integration-test, ux
// @PURPOSE: Validate confirm/cancel UX contract bindings in assistant chat panel source.
// @LAYER: UI Tests
// @RELATION: VERIFIES -> frontend/src/lib/components/assistant/AssistantChatPanel.svelte
// @INVARIANT: Confirm/cancel action handling must remain explicit and confirmation-id bound.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import AssistantChatPanel from '../AssistantChatPanel.svelte';
import * as api from '$lib/api/assistant';
import { assistantChatStore } from '$lib/stores/assistantChat';
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Mock dependencies
vi.mock('$lib/api/assistant', () => ({
getAssistantHistory: vi.fn(),
confirmAssistantOperation: vi.fn(),
cancelAssistantOperation: vi.fn(),
getAssistantConversations: vi.fn(() => Promise.resolve({ items: [], total: 0 })),
sendAssistantMessage: vi.fn()
}));
const COMPONENT_PATH = path.resolve(
process.cwd(),
'src/lib/components/assistant/AssistantChatPanel.svelte',
);
vi.mock('$lib/toasts', () => ({
addToast: vi.fn()
}));
// [DEF:assistant_confirmation_contract_tests:Function]
// @PURPOSE: Assert that confirmation UX flow and API bindings are preserved in chat panel.
// @PRE: Assistant panel source file exists and is readable.
// @POST: Test guarantees explicit confirm/cancel guards and failed-action recovery path.
describe('AssistantChatPanel confirmation integration contract', () => {
it('contains confirmation action guards with confirmation_id checks', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
vi.mock('$lib/stores/assistantChat', () => ({
assistantChatStore: {
subscribe: (fn) => {
fn({ isOpen: true, conversationId: 'conv-1' });
return () => { };
}
},
closeAssistantChat: vi.fn(),
setAssistantConversationId: vi.fn()
}));
expect(source).toContain('if (action.type === "confirm" && message.confirmation_id)');
expect(source).toContain('if (action.type === "cancel" && message.confirmation_id)');
expect(source).toContain('confirmAssistantOperation(\n message.confirmation_id,\n )');
expect(source).toContain('cancelAssistantOperation(\n message.confirmation_id,\n )');
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/i18n', () => ({
t: {
subscribe: (fn) => {
fn({
assistant: {
action_failed: 'Action failed',
confirm: 'Confirm',
cancel: 'Cancel'
}
});
return () => { };
}
}
}));
describe('AssistantChatPanel confirmation functional tests', () => {
const mockMessage = {
id: 'msg-123',
role: 'assistant',
text: 'Confirm migration?',
created_at: new Date().toISOString(),
confirmation: {
id: 'conf-123',
type: 'migration_execute',
status: 'pending'
}
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders action buttons from assistant response payload', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
it('renders action buttons and triggers confirm API call', async () => {
// Mock getAssistantHistory to return our message
api.getAssistantHistory.mockResolvedValue({
items: [mockMessage],
total: 1,
has_next: false
});
expect(source).toContain('{#if message.actions?.length}');
expect(source).toContain('{#each message.actions as action}');
expect(source).toContain('{action.label}');
expect(source).toContain('on:click={() => handleAction(action, message)}');
render(AssistantChatPanel);
// Wait for message to render
await waitFor(() => {
expect(screen.getByText('Confirm migration?')).toBeTruthy();
});
const confirmBtn = screen.getByText('Confirm');
expect(confirmBtn).toBeTruthy();
await fireEvent.click(confirmBtn);
expect(api.confirmAssistantOperation).toHaveBeenCalledWith('conf-123');
});
it('keeps failed-action recovery response path', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
it('triggers cancel API call when cancel button is clicked', async () => {
api.getAssistantHistory.mockResolvedValue({
items: [mockMessage],
total: 1,
has_next: false
});
expect(source).toContain('response_id: `action-error-${Date.now()}`');
expect(source).toContain('state: "failed"');
expect(source).toContain('text: err.message || "Action failed"');
render(AssistantChatPanel);
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeTruthy();
});
const cancelBtn = screen.getByText('Cancel');
await fireEvent.click(cancelBtn);
expect(api.cancelAssistantOperation).toHaveBeenCalledWith('conf-123');
});
it('shows toast error when action fails', async () => {
api.getAssistantHistory.mockResolvedValue({
items: [mockMessage],
total: 1,
has_next: false
});
api.confirmAssistantOperation.mockRejectedValue(new Error('Network error'));
render(AssistantChatPanel);
await waitFor(() => screen.getByText('Confirm'));
await fireEvent.click(screen.getByText('Confirm'));
await waitFor(() => {
// The component appends a failed message to the chat
expect(screen.getAllByText(/Network error/)).toBeTruthy();
});
});
});
// [/DEF:assistant_confirmation_contract_tests:Function]
// [/DEF:frontend.src.lib.components.assistant.__tests__.assistant_confirmation_integration:Module]

View File

@@ -18,7 +18,7 @@
* @UX_RECOVERY: Back button shows task list when viewing task details
*/
import { onMount, onDestroy } from "svelte";
import { onDestroy } from "svelte";
import { taskDrawerStore, closeDrawer } from "$lib/stores/taskDrawer.js";
import { assistantChatStore } from "$lib/stores/assistantChat.js";
import TaskLogViewer from "../../../components/TaskLogViewer.svelte";
@@ -26,6 +26,8 @@
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import Icon from "$lib/ui/Icon.svelte";
import { addToast } from "$lib/toasts.js";
import { gitService } from "../../../services/gitService.js";
let isOpen = false;
let activeTaskId = null;
@@ -35,6 +37,13 @@
let recentTasks = [];
let loadingTasks = false;
let isAssistantOpen = false;
let activeTaskDetails = null;
let environmentOptions = [];
let taskDetailsPollInterval = null;
let diffText = "";
let showDiff = false;
let isDiffLoading = false;
let taskSummary = null;
// Subscribe to task drawer store
$: if ($taskDrawerStore) {
@@ -71,21 +80,195 @@
}
}
function normalizeTaskId(rawTaskId) {
if (!rawTaskId) return "";
if (typeof rawTaskId === "string") {
const match = rawTaskId.match(
/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i,
);
return match ? match[0] : rawTaskId;
}
return rawTaskId?.id || rawTaskId?.task_id || "";
}
function isTaskFinished(status) {
return (
status === "SUCCESS" ||
status === "FAILED" ||
status === "ERROR" ||
status === "PARTIAL_SUCCESS"
);
}
function stopTaskDetailsPolling() {
if (taskDetailsPollInterval) {
clearInterval(taskDetailsPollInterval);
taskDetailsPollInterval = null;
}
}
async function loadEnvironmentOptions() {
try {
const response = await api.getEnvironments();
environmentOptions = Array.isArray(response) ? response : [];
} catch (err) {
console.warn("[TaskDrawer][loadEnvironmentOptions][FAILED]", err);
environmentOptions = [];
}
}
function resolveEnvironmentName(envId, fallbackName = null) {
if (fallbackName) return fallbackName;
const env = environmentOptions.find((item) => item.id === envId);
return env?.name || envId || $t.common?.not_available || "N/A";
}
function resolveEnvironmentId(rawEnvIdOrName) {
if (!rawEnvIdOrName) return null;
const byId = environmentOptions.find((item) => item.id === rawEnvIdOrName);
if (byId) return byId.id;
const byName = environmentOptions.find(
(item) => item.name === rawEnvIdOrName,
);
return byName?.id || rawEnvIdOrName;
}
async function loadActiveTaskDetails() {
const taskId = normalizeTaskId(activeTaskId);
if (!taskId) return;
try {
const task = await api.getTask(taskId);
activeTaskDetails = task;
taskStatus = task?.status || taskStatus;
if (isTaskFinished(task?.status)) {
stopTaskDetailsPolling();
}
} catch (err) {
console.error("[TaskDrawer][loadActiveTaskDetails][FAILED]", err);
}
}
function extractPrimaryDashboardId(task) {
const result = task?.result || {};
const params = task?.params || {};
if (Array.isArray(result?.migrated_dashboards) && result.migrated_dashboards[0]?.id) {
return result.migrated_dashboards[0].id;
}
if (Array.isArray(result?.dashboards) && result.dashboards[0]?.id) {
return result.dashboards[0].id;
}
if (Array.isArray(params?.dashboard_ids) && params.dashboard_ids[0]) {
return params.dashboard_ids[0];
}
if (params?.dashboard_id) return params.dashboard_id;
return null;
}
function buildTaskSummary(task) {
if (!task || !isTaskFinished(task?.status)) return null;
const result = task?.result || {};
const params = task?.params || {};
const summary = {
headline: result?.summary || null,
lines: [],
warnings: [],
primaryDashboardId: extractPrimaryDashboardId(task),
targetEnvId: null,
targetEnvName: null,
};
if (task.plugin_id === "superset-migration") {
const migrated = Array.isArray(result?.migrated_dashboards)
? result.migrated_dashboards.length
: 0;
const failed = Array.isArray(result?.failed_dashboards)
? result.failed_dashboards.length
: 0;
const selected = result?.selected_dashboards ?? migrated + failed;
const mappings = result?.mapping_count ?? 0;
summary.lines.push(
`${$t.tasks?.selected || "Selected"}: ${selected}, ${$t.tasks?.successful || "Successful"}: ${migrated}, ${$t.tasks?.with_errors || "With errors"}: ${failed}, ${$t.tasks?.mappings || "Mappings"}: ${mappings}`,
);
summary.targetEnvId = resolveEnvironmentId(params?.target_env_id || null);
summary.targetEnvName = resolveEnvironmentName(
summary.targetEnvId,
result?.target_environment || null,
);
if (failed > 0) {
summary.warnings = (result.failed_dashboards || [])
.slice(0, 3)
.map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`);
}
return summary;
}
if (task.plugin_id === "superset-backup") {
const total = result?.total_dashboards ?? 0;
const ok = result?.backed_up_dashboards ?? 0;
const failed = result?.failed_dashboards ?? 0;
summary.lines.push(
`${$t.tasks?.total || "Total"}: ${total}, ${$t.tasks?.successful || "Successful"}: ${ok}, ${$t.tasks?.failed || "Failed"}: ${failed}`,
);
summary.targetEnvId = resolveEnvironmentId(
params?.environment_id || params?.env || null,
);
summary.targetEnvName = resolveEnvironmentName(
summary.targetEnvId,
result?.environment || null,
);
if (Array.isArray(result?.failures) && result.failures.length > 0) {
summary.warnings = result.failures
.slice(0, 3)
.map((item) => `${item?.title || item?.id}: ${item?.error || $t.common?.unknown || "Unknown error"}`);
}
return summary;
}
if (result?.summary) {
summary.lines.push(result.summary);
return summary;
}
return null;
}
async function handleOpenDashboardDeepLink() {
if (!taskSummary?.primaryDashboardId || !taskSummary?.targetEnvId) {
addToast($t.tasks?.summary_link_unavailable || "Deep link unavailable", "error");
return;
}
const href = `/dashboards/${encodeURIComponent(String(taskSummary.primaryDashboardId))}?env_id=${encodeURIComponent(String(taskSummary.targetEnvId))}`;
window.open(href, "_blank", "noopener,noreferrer");
}
async function handleShowDiff() {
if (!taskSummary?.primaryDashboardId) {
addToast($t.tasks?.summary_link_unavailable || "Diff unavailable", "error");
return;
}
showDiff = true;
isDiffLoading = true;
diffText = "";
try {
const diffPayload = await gitService.getDiff(taskSummary.primaryDashboardId);
diffText =
typeof diffPayload === "string"
? diffPayload
: diffPayload?.diff || JSON.stringify(diffPayload, null, 2);
} catch (err) {
addToast(err?.message || "Failed to load diff", "error");
diffText = "";
} finally {
isDiffLoading = false;
}
}
// Connect to WebSocket for real-time logs
function connectWebSocket() {
if (!activeTaskId) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
let taskId = "";
if (typeof activeTaskId === "string") {
const match = activeTaskId.match(
/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i,
);
taskId = match ? match[0] : activeTaskId;
} else {
taskId = activeTaskId?.id || activeTaskId?.task_id || activeTaskId;
}
const taskId = normalizeTaskId(activeTaskId);
const wsUrl = `${protocol}//${host}/ws/logs/${taskId}`;
console.log(`[TaskDrawer][Action] Connecting to WebSocket: ${wsUrl}`);
@@ -198,16 +381,30 @@
if (activeTaskId) {
disconnectWebSocket();
realTimeLogs = [];
showDiff = false;
diffText = "";
taskStatus = "RUNNING";
connectWebSocket();
loadEnvironmentOptions();
loadActiveTaskDetails();
stopTaskDetailsPolling();
taskDetailsPollInterval = setInterval(loadActiveTaskDetails, 4000);
} else {
// List mode - load recent tasks
stopTaskDetailsPolling();
activeTaskDetails = null;
loadRecentTasks();
}
} else {
stopTaskDetailsPolling();
activeTaskDetails = null;
}
$: taskSummary = buildTaskSummary(activeTaskDetails);
// Cleanup on destroy
onDestroy(() => {
stopTaskDetailsPolling();
disconnectWebSocket();
});
</script>
@@ -285,6 +482,77 @@
<!-- Content -->
<div class="flex-1 overflow-hidden flex flex-col">
{#if activeTaskId}
{#if taskSummary}
<div class="mx-4 mt-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold text-slate-900">
{$t.tasks?.summary_report || "Summary report"}
</h3>
<span class="rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-700">
{taskStatus}
</span>
</div>
{#if taskSummary.headline}
<p class="mb-2 text-sm text-slate-700">{taskSummary.headline}</p>
{/if}
{#if taskSummary.lines.length > 0}
<ul class="mb-2 space-y-1 text-xs text-slate-700">
{#each taskSummary.lines as line}
<li>{line}</li>
{/each}
</ul>
{/if}
{#if taskSummary.warnings.length > 0}
<div class="mb-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<p class="mb-1 font-semibold">
{$t.tasks?.observability_warnings || "Warnings"}
</p>
<ul class="space-y-1">
{#each taskSummary.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
</div>
{/if}
<div class="flex flex-wrap gap-2">
<button
class="rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
on:click={handleOpenDashboardDeepLink}
disabled={!taskSummary?.primaryDashboardId || !taskSummary?.targetEnvId}
>
{#if taskSummary?.targetEnvName}
{($t.tasks?.open_dashboard_target || "Open dashboard in {env}").replace(
"{env}",
taskSummary.targetEnvName,
)}
{:else}
{$t.tasks?.open_dashboard_target_fallback || "Open dashboard"}
{/if}
</button>
<button
class="rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
on:click={handleShowDiff}
disabled={!taskSummary?.primaryDashboardId}
>
{$t.tasks?.show_diff || "Show diff"}
</button>
</div>
{#if showDiff}
<div class="mt-3 rounded-md border border-slate-200 bg-white p-2">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.tasks?.diff_preview || "Diff preview"}
</p>
{#if isDiffLoading}
<p class="text-xs text-slate-500">{$t.git?.loading_diff || "Loading diff..."}</p>
{:else if diffText}
<pre class="max-h-40 overflow-auto rounded bg-slate-900 p-2 text-[11px] text-slate-100">{diffText}</pre>
{:else}
<p class="text-xs text-slate-500">{$t.tasks?.no_diff_available || "No diff available"}</p>
{/if}
</div>
{/if}
</div>
{/if}
<TaskLogViewer
inline={true}
taskId={activeTaskId}

View File

@@ -13,58 +13,76 @@
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
*/
import { createEventDispatcher } from 'svelte';
import { t } from '$lib/i18n';
import { getReportTypeProfile } from './reportTypeProfiles.js';
import { createEventDispatcher } from "svelte";
import { t } from "$lib/i18n";
import { getReportTypeProfile } from "./reportTypeProfiles.js";
let { report, selected = false } = $props();
let { report, selected = false, onselect } = $props();
const dispatch = createEventDispatcher();
const profile = $derived(getReportTypeProfile(report?.task_type));
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
const profileLabel = $derived(
typeof profile?.label === "function" ? profile.label() : profile?.label,
);
function getStatusClass(status) {
if (status === 'success') return 'bg-green-100 text-green-700 ring-1 ring-green-200';
if (status === 'failed') return 'bg-red-100 text-red-700 ring-1 ring-red-200';
if (status === 'in_progress') return 'bg-blue-100 text-blue-700 ring-1 ring-blue-200';
if (status === 'partial') return 'bg-amber-100 text-amber-700 ring-1 ring-amber-200';
return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200';
if (status === "success")
return "bg-green-100 text-green-700 ring-1 ring-green-200";
if (status === "failed")
return "bg-red-100 text-red-700 ring-1 ring-red-200";
if (status === "in_progress")
return "bg-blue-100 text-blue-700 ring-1 ring-blue-200";
if (status === "partial")
return "bg-amber-100 text-amber-700 ring-1 ring-amber-200";
return "bg-slate-100 text-slate-700 ring-1 ring-slate-200";
}
function getStatusLabel(status) {
if (status === 'success') return $t.reports?.status_success ;
if (status === 'failed') return $t.reports?.status_failed ;
if (status === 'in_progress') return $t.reports?.status_in_progress ;
if (status === 'partial') return $t.reports?.status_partial ;
return status || ($t.reports?.not_provided );
if (status === "success") return $t.reports?.status_success;
if (status === "failed") return $t.reports?.status_failed;
if (status === "in_progress") return $t.reports?.status_in_progress;
if (status === "partial") return $t.reports?.status_partial;
return status || $t.reports?.not_provided;
}
function formatDate(value) {
if (!value) return $t.reports?.not_provided ;
if (!value) return $t.reports?.not_provided;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided ;
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided;
return date.toLocaleString();
}
function onSelect() {
dispatch('select', { report });
if (onselect) onselect({ report });
dispatch("select", { report });
}
</script>
<button
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-slate-300 hover:bg-slate-50 hover:shadow {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
on:click={onSelect}
aria-label={`${$t.reports?.title} ${report?.report_id || ''} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:border-slate-300 hover:bg-slate-50 hover:shadow {selected
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 bg-white'}"
onclick={onSelect}
aria-label={`${$t.reports?.title} ${report?.report_id || ""} ${$t.reports?.type} ${profileLabel || $t.reports?.unknown_type}`}
>
<div class="mb-2 flex items-center justify-between gap-2">
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
<span
class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant ||
'bg-slate-100 text-slate-700'}"
>
{profileLabel || $t.reports?.unknown_type}
</span>
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
<span
class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(
report?.status,
)}"
>
{getStatusLabel(report?.status)}
</span>
</div>
<p class="text-sm font-medium text-slate-800">{report?.summary || $t.reports?.not_provided}</p>
<p class="text-sm font-medium text-slate-800">
{report?.summary || $t.reports?.not_provided}
</p>
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
</button>

View File

@@ -21,7 +21,11 @@ vi.mock('$lib/i18n', () => ({
fn({
reports: {
not_provided: 'Not provided',
unknown_type: 'Other / Unknown Type'
unknown_type: 'Other / Unknown Type',
status_success: 'Success',
status_failed: 'Failed',
status_in_progress: 'In Progress',
status_partial: 'Partial'
}
});
return () => { };
@@ -37,7 +41,7 @@ describe('ReportCard UX Contract', () => {
it('should display summary, status and type in Ready state', () => {
render(ReportCard, { report: mockReport });
expect(screen.getByText(mockReport.summary)).toBeDefined();
// mockReport.status is "success", getStatusLabel(status) returns "Success"
// mockReport.status is "success", getStatusLabel(status) returns $t.reports?.status_success
expect(screen.getByText('Success')).toBeDefined();
// Profile label for llm_verification is 'LLM'
expect(screen.getByText('LLM')).toBeDefined();
@@ -45,15 +49,13 @@ describe('ReportCard UX Contract', () => {
// @UX_FEEDBACK: Click on report emits select event.
it('should emit select event on click', async () => {
// In Svelte 5 / Vitest environment, we test event dispatching by passing the handler as a prop
// with 'on' prefix (e.g., onselect) or by using standard event listeners if component supports them.
const onSelect = vi.fn();
render(ReportCard, { report: mockReport, onselect: onSelect });
const button = screen.getByRole('button');
await fireEvent.click(button);
// Note: Svelte 5 event dispatching testing depends on testing-library version and component implementation.
expect(onSelect).toHaveBeenCalled();
});
// @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.

View File

@@ -24,11 +24,27 @@ vi.mock('$lib/i18n', () => ({
fn({
settings: {
title: 'Settings',
migration: 'Migration Sync',
migration_sync: 'Migration Sync',
migration_sync_title: 'Cross-Environment ID Synchronization',
migration_sync_description: 'Sync IDs across environments',
sync_schedule: 'Sync Schedule',
sync_now: 'Sync Now',
syncing: 'Syncing...',
saving: 'Saving...',
environments: 'Environments',
logging: 'Logging',
connections: 'Connections',
llm: 'LLM',
storage: 'Storage',
save_success: 'Settings saved',
save_failed: 'Failed'
save_failed: 'Failed',
env_description: 'Configure environments',
migration_sync_failed: 'Sync failed'
},
common: { refresh: 'Refresh' }
common: { refresh: 'Refresh', save: 'Save' },
tasks: { cron_label: 'Cron Expression', cron_hint: 'Standard cron format' },
nav: { settings_git: 'Git' },
connections: { name: 'Name', user: 'User' }
});
return () => { };
}

View File

@@ -28,11 +28,33 @@ vi.mock('$lib/i18n', () => ({
fn({
settings: {
title: 'Settings',
migration: 'Migration Sync',
migration_sync: 'Migration Sync',
migration_sync_title: 'Cross-Environment ID Synchronization',
migration_sync_description: 'Sync IDs across environments',
sync_schedule: 'Sync Schedule',
sync_now: 'Sync Now',
syncing: 'Syncing...',
saving: 'Saving...',
environments: 'Environments',
logging: 'Logging',
logging_description: 'Configure logging',
log_level: 'Log Level',
task_log_level: 'Task Log Level',
enable_belief_state: 'Enable Belief State',
connections: 'Connections',
llm: 'LLM',
storage: 'Storage',
save_success: 'Settings saved',
save_failed: 'Failed'
save_failed: 'Failed',
save_logging: 'Save Logging Config',
env_description: 'Configure environments',
load_failed: 'Failed to load settings',
migration_sync_failed: 'Sync failed'
},
common: { refresh: 'Refresh', retry: 'Retry' }
common: { refresh: 'Refresh', retry: 'Retry', save: 'Save' },
tasks: { cron_label: 'Cron Expression', cron_hint: 'Standard cron format' },
nav: { settings_git: 'Git' },
connections: { name: 'Name', user: 'User' }
});
return () => { };
}
@@ -74,7 +96,11 @@ describe('SettingsPage UX Contracts', () => {
resolveSettings = resolve;
}));
api.requestApi.mockResolvedValue(mockMigrationSettings);
api.requestApi.mockImplementation((url) => {
if (url === '/migration/settings') return Promise.resolve(mockMigrationSettings);
if (url.includes('/migration/mappings-data')) return Promise.resolve({ items: [], total: 0 });
return Promise.resolve({});
});
render(SettingsPage);
@@ -86,10 +112,11 @@ describe('SettingsPage UX Contracts', () => {
// Resolve the API call
resolveSettings(mockSettings);
// Assert Loaded state
// Assert Loaded state - use getAllByText because 'Environments' may appear
// both as tab text and section heading on the default tab
await waitFor(() => {
expect(screen.getByText('Settings')).toBeTruthy();
expect(screen.getByText('Environments')).toBeTruthy();
expect(screen.getAllByText('Environments').length).toBeGreaterThan(0);
});
});
@@ -115,7 +142,11 @@ describe('SettingsPage UX Contracts', () => {
return mockSettings;
});
api.requestApi.mockResolvedValue(mockMigrationSettings);
api.requestApi.mockImplementation((url) => {
if (url === '/migration/settings') return Promise.resolve(mockMigrationSettings);
if (url.includes('/migration/mappings-data')) return Promise.resolve({ items: [], total: 0 });
return Promise.resolve({});
});
render(SettingsPage);
@@ -128,10 +159,11 @@ describe('SettingsPage UX Contracts', () => {
const retryBtn = screen.getByText('Retry');
await fireEvent.click(retryBtn);
// Verify recovery (Loaded state)
// Verify recovery (Loaded state) - use getAllByText because 'Environments'
// appears both as tab text and section heading
await waitFor(() => {
expect(screen.queryByText('First call failed')).toBeNull();
expect(screen.getByText('Environments')).toBeTruthy();
expect(screen.getAllByText('Environments').length).toBeGreaterThan(0);
});
// We expect it to have been called twice (1. initial mount, 2. retry click)
expect(api.getConsolidatedSettings).toHaveBeenCalledTimes(2);
@@ -147,7 +179,8 @@ describe('SettingsPage UX Contracts', () => {
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
// Navigate to Logging tab where the Save button is
await fireEvent.click(screen.getByText('Logging'));
// 'Logging' appears in both the tab and section heading; use getAllByText
await fireEvent.click(screen.getAllByText('Logging')[0]);
const saveBtn = screen.getByText('Save Logging Config');
await fireEvent.click(saveBtn);
@@ -166,7 +199,8 @@ describe('SettingsPage UX Contracts', () => {
await waitFor(() => expect(screen.getByText('Settings')).toBeTruthy());
// Navigate to Logging tab where the Save button is
await fireEvent.click(screen.getByText('Logging'));
// 'Logging' appears in both the tab and section heading; use getAllByText
await fireEvent.click(screen.getAllByText('Logging')[0]);
const saveBtn = screen.getByText('Save Logging Config');
await fireEvent.click(saveBtn);