Files
sencho/frontend/src/components/EditorLayout/__tests__/RecoveryChip.test.tsx
T
Anso d369b03a38 feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions

Add a backend idle-output backstop that stops a deploy/update compose step
that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a
hung image pull surfaces a fast failure instead of spinning indefinitely.

Surface failed, timed-out, and stalled operations with recovery actions on
the stack page: a desktop chip plus popover menu and an inline mobile card
offering retry, restart, roll back (when a backup exists), refresh state,
and copy diagnostics, all gated by deploy permission. The streaming
deploy/update progress modal is now on by default and warns when output
goes quiet. Container state is refreshed after a failed or stalled
operation, and the UI never sits in an indefinite spinner.

* fix: harden rollback against policy-blocked file mutation and refine recovery

Address review findings on the stalled-update recovery work:

- The rollback route restored backup files before running the policy gate, so
  a policy-blocked rollback could leave the on-disk config rolled back while the
  deployed containers were unchanged. Snapshot the current files first and
  revert them when the gate blocks; if that revert itself fails, escalate it on
  the persistent alert feed since the 409 is already sent.
- Refresh container state after a successful manual rollback (rollback
  redeploys), without mis-recording a refetch failure as a rollback failure.
- Suppress the stalled-output warning once live progress is unavailable.

* test: mock snapshotStackFiles in the atomic-deploy rollback route tests

The rollback route now snapshots stack files before restoring a backup, so its
FileSystemService mock needs snapshotStackFiles. Without it the mocked call
threw and the route returned 500, failing the success-path rollback assertions.
2026-06-10 10:12:24 -04:00

61 lines
2.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoveryChip } from '../RecoveryChip';
import type { StackActionResult } from '../EditorView';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseResult: StackActionResult = {
action: 'update',
rolledBack: false,
errorMessage: 'pull failed: connection reset',
startedAt: 1000,
endedAt: 4000,
};
function setup(over: Partial<Parameters<typeof RecoveryChip>[0]> = {}) {
const props = {
stackName: 'web',
result: baseResult,
activeNode: null,
backupInfo: { exists: false, timestamp: null },
canDeploy: true,
onRetry: vi.fn(),
onRestart: vi.fn(),
onRollback: vi.fn(),
onRefreshState: vi.fn(),
onDismiss: vi.fn(),
...over,
};
render(<RecoveryChip {...props} />);
return props;
}
describe('RecoveryChip', () => {
beforeEach(() => vi.clearAllMocks());
it('renders a chip with the failed action and keeps actions hidden until opened', () => {
setup();
expect(screen.getByTestId('recovery-chip')).toBeInTheDocument();
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
});
it('reveals the recovery actions when the chip is clicked', () => {
const props = setup();
fireEvent.click(screen.getByTestId('recovery-chip'));
expect(screen.getByText('Retry update')).toBeInTheDocument();
expect(screen.getByText('Refresh')).toBeInTheDocument();
fireEvent.click(screen.getByText('Retry update'));
expect(props.onRetry).toHaveBeenCalledTimes(1);
});
it('dismisses from inside the popover', () => {
const props = setup();
fireEvent.click(screen.getByTestId('recovery-chip'));
fireEvent.click(screen.getByText('Dismiss'));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
});