codex specify
This commit is contained in:
@@ -21,6 +21,9 @@ vi.mock('../../lib/i18n', () => ({
|
||||
fn({
|
||||
tasks: {
|
||||
loading: 'Loading...'
|
||||
},
|
||||
common: {
|
||||
retry: 'Retry'
|
||||
}
|
||||
});
|
||||
return () => { };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => { };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user