mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
fix: bind deploy progress, request, and health gate to the captured node (#1357)
* fix: bind deploy progress, request, and health gate to the captured node A deploy/update/install/git-apply re-read the active node from localStorage independently at three points: the progress WebSocket at mount, the POST at call time, and the health-gate poll. If the active node changed between the click and any of those, the operation, its live output, and its health verdict could target different nodes, and the socket and POST splitting across nodes broke output streaming. Capture the operation's node once when it starts and thread it through every leg. A new nodeId option on apiFetch overrides the active-node read, the progress terminal takes a nodeId prop for its socket URL, the health gate polls on the captured node, and a failed gate records its recovery entry only on the node it ran on. The surface, the request, and the gate now always agree. * fix: scope failed-gate recovery to the file list's node and harden node targeting Addresses review findings on the captured-node binding: - Track the node the stack file list was fetched for (filesNodeId) and record a failed gate's recovery entry only when it matches the gate's node. This closes a race where switching back to the gate's node could match a same-named stack from the previous node's still-loaded list before the new list lands, keying the record to the wrong file and blocking the correct one. refreshStacks now carries a sequence token so an out-of-order resolution cannot leave files and filesNodeId inconsistent. - Make an explicit apiFetch nodeId authoritative over a caller-supplied x-node-id header. - Add the missing stack-logs nodeId cases (null, and active-node fallback) to the terminal tests.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Verifies that an App Store install binds both the runWithLog session and the
|
||||
* /templates/deploy POST to the node captured when the install starts, so the
|
||||
* install does not retarget if the active node changes while images pull. The
|
||||
* heavy child components are stubbed to a minimal select-then-deploy path.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const dfCtl = vi.hoisted(() => ({ params: null as null | { stackName: string; action: string; nodeId: number | null } }));
|
||||
|
||||
function jsonRes(body: unknown, ok = true, status = 200) {
|
||||
return { ok, status, json: async () => body, text: async () => '' } as unknown as Response;
|
||||
}
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
withDeploySession: (ds: string, options: RequestInit = {}) => ({
|
||||
...options,
|
||||
headers: { ...(options.headers as Record<string, string> | undefined), 'x-deploy-session-id': ds },
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 5, type: 'local', name: 'local' } }) }));
|
||||
vi.mock('@/context/DeployFeedbackContext', () => ({
|
||||
useDeployFeedback: () => ({
|
||||
runWithLog: vi.fn(
|
||||
async (
|
||||
params: { stackName: string; action: string; nodeId: number | null },
|
||||
run: (started: Promise<void>, ds: string) => Promise<{ ok: boolean }>,
|
||||
) => {
|
||||
dfCtl.params = params;
|
||||
return run(Promise.resolve(), 'ds-1');
|
||||
},
|
||||
),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/components/appstore/CategorySidebar', () => ({ CategorySidebar: () => null }));
|
||||
// Both featured and grid surfaces select the template; whichever renders, one
|
||||
// select button exists.
|
||||
vi.mock('@/components/appstore/TemplateTile', () => ({
|
||||
TemplateTile: ({ template, onSelect }: { template: { title: string }; onSelect: (t: unknown) => void }) => (
|
||||
<button data-testid="select-template" onClick={() => onSelect(template)}>{template.title}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/appstore/FeaturedHero', () => ({
|
||||
FeaturedHero: ({ template, onOpen }: { template: { title: string }; onOpen: (t: unknown) => void }) => (
|
||||
<button data-testid="select-template" onClick={() => onOpen(template)}>{template.title}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/ui/system-sheet', () => ({
|
||||
SystemSheet: ({ open, primaryAction }: { open: boolean; primaryAction?: { onClick: () => void; disabled?: boolean } }) =>
|
||||
open && primaryAction
|
||||
? <button data-testid="deploy" onClick={primaryAction.onClick} disabled={primaryAction.disabled}>deploy</button>
|
||||
: null,
|
||||
SheetSection: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { AppStoreView } from '../AppStoreView';
|
||||
|
||||
const TEMPLATE = {
|
||||
title: 'Nginx',
|
||||
description: 'web server',
|
||||
categories: ['Web'],
|
||||
env: [],
|
||||
ports: [],
|
||||
volumes: [],
|
||||
architectures: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dfCtl.params = null;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/templates/deploy')) return Promise.resolve(jsonRes({}));
|
||||
if (u.includes('/templates')) return Promise.resolve(jsonRes([TEMPLATE]));
|
||||
if (u.includes('/stacks')) return Promise.resolve(jsonRes([]));
|
||||
return Promise.resolve(jsonRes({}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppStoreView install node binding', () => {
|
||||
it('binds both runWithLog and the /templates/deploy POST to the captured node', async () => {
|
||||
render(<AppStoreView onDeploySuccess={vi.fn()} />);
|
||||
|
||||
fireEvent.click((await screen.findAllByTestId('select-template'))[0]);
|
||||
fireEvent.click(await screen.findByTestId('deploy'));
|
||||
|
||||
await waitFor(() => {
|
||||
const deployCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/templates/deploy'));
|
||||
expect(deployCall?.[1]).toEqual(expect.objectContaining({ nodeId: 5 }));
|
||||
});
|
||||
expect(dfCtl.params).toEqual(expect.objectContaining({ action: 'install', nodeId: 5 }));
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,14 @@ import { apiFetch } from '@/lib/api';
|
||||
|
||||
// Lets a test simulate a mid-stream drop (onReady then onError) so the panel
|
||||
// reaches 'streaming' with progressUnavailable set.
|
||||
const ctl = vi.hoisted(() => ({ drop: false }));
|
||||
const ctl = vi.hoisted(() => ({ drop: false, lastNodeId: undefined as number | null | undefined }));
|
||||
|
||||
// The real Terminal mounts xterm + a WebSocket; mock it to a no-op that signals
|
||||
// the stream connected on mount so the panel reaches the 'streaming' state.
|
||||
// the stream connected on mount so the panel reaches the 'streaming' state, and
|
||||
// records the captured nodeId it was mounted with.
|
||||
vi.mock('@/components/Terminal', () => {
|
||||
const MockTerminal = ({ onReady, onError }: { onReady?: () => void; onError?: () => void }) => {
|
||||
const MockTerminal = ({ onReady, onError, nodeId }: { onReady?: () => void; onError?: () => void; nodeId?: number | null }) => {
|
||||
ctl.lastNodeId = nodeId;
|
||||
React.useEffect(() => {
|
||||
onReady?.();
|
||||
if (ctl.drop) onError?.();
|
||||
@@ -29,11 +31,13 @@ vi.mock('@/components/Terminal', () => {
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string; healthGateId?: string | null }) => void) | null = null;
|
||||
// The runWithLog promise itself, so a test can await full result propagation.
|
||||
let runOuter: Promise<unknown> | null = null;
|
||||
// Node the driver captures for the operation; default local, overridden per test.
|
||||
let driverNodeId: number | null = null;
|
||||
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: null }, async (started) => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: driverNodeId }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
|
||||
});
|
||||
@@ -77,6 +81,8 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
localStorage.clear();
|
||||
resolveRun = null;
|
||||
ctl.drop = false;
|
||||
ctl.lastNodeId = undefined;
|
||||
driverNodeId = null;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
});
|
||||
@@ -97,6 +103,12 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('binds the modal progress terminal to the captured panel node', async () => {
|
||||
driverNodeId = 5;
|
||||
await renderStreaming();
|
||||
expect(ctl.lastNodeId).toBe(5);
|
||||
});
|
||||
|
||||
it('shows the observing banner and suspends auto-close while the gate observes', async () => {
|
||||
routeGateApi([{ id: 'gate-1', status: 'observing' }]);
|
||||
await succeedWithGate('gate-1');
|
||||
|
||||
@@ -35,7 +35,13 @@ vi.mock('../DeployFeedbackModal', () => ({ DeployFeedbackModal: () => <div data-
|
||||
vi.mock('../DeployFeedbackPill', () => ({
|
||||
DeployFeedbackPill: ({ isVisible }: { isVisible: boolean }) => (isVisible ? <div data-testid="pill-marker" /> : null),
|
||||
}));
|
||||
vi.mock('../Terminal', () => ({ default: () => <div data-testid="portal-terminal" /> }));
|
||||
const termSpy = vi.hoisted(() => ({ nodeId: undefined as number | null | undefined }));
|
||||
vi.mock('../Terminal', () => ({
|
||||
default: (props: { nodeId?: number | null }) => {
|
||||
termSpy.nodeId = props.nodeId;
|
||||
return <div data-testid="portal-terminal" />;
|
||||
},
|
||||
}));
|
||||
|
||||
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
return {
|
||||
@@ -61,6 +67,13 @@ describe('DeployFeedbackPortal', () => {
|
||||
expect(screen.getByTestId('portal-terminal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('binds the inline progress terminal to the captured panel node', () => {
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel({ isOpen: true, nodeId: 7 });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(termSpy.nodeId).toBe(7);
|
||||
});
|
||||
|
||||
it('does not mount the portal terminal in modal style (the modal owns it)', () => {
|
||||
mockStyle = 'modal';
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Unit tests for TerminalComponent's WebSocket URL construction, specifically
|
||||
* that a captured `nodeId` prop binds the socket to that node (number, or null
|
||||
* for local) and that an absent prop falls back to the active node. This is the
|
||||
* progress-stream half of keeping a deploy bound to the node it started on.
|
||||
*/
|
||||
import { render, waitFor, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// The real loader pulls the ~660 KB xterm chunk; stub it so init resolves
|
||||
// synchronously with no-op terminal/addons and the socket is built immediately.
|
||||
vi.mock('@/lib/xtermLoader', () => {
|
||||
class FakeTerminal {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
attachCustomKeyEventHandler() {}
|
||||
write() {}
|
||||
dispose() {}
|
||||
}
|
||||
class FakeFit { fit() {} }
|
||||
class FakeSearch { findNext() {} findPrevious() {} }
|
||||
class FakeSerialize { serialize() { return ''; } }
|
||||
return {
|
||||
loadXtermModules: vi.fn().mockResolvedValue({
|
||||
Terminal: FakeTerminal,
|
||||
FitAddon: FakeFit,
|
||||
SearchAddon: FakeSearch,
|
||||
SerializeAddon: FakeSerialize,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/terminalTheme', () => ({ buildXtermTheme: () => ({}) }));
|
||||
|
||||
import TerminalComponent from '../Terminal';
|
||||
|
||||
class MockWS {
|
||||
static instances: MockWS[] = [];
|
||||
url: string;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((e?: unknown) => void) | null = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
constructor(url: string) { this.url = url; MockWS.instances.push(this); }
|
||||
static reset() { MockWS.instances = []; }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MockWS.reset();
|
||||
vi.stubGlobal('WebSocket', MockWS);
|
||||
localStorage.setItem('sencho-active-node', '9');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Mount and resolve the socket URL. Init runs after a 50ms timer + the async
|
||||
// loader, so poll until the socket is constructed rather than racing it.
|
||||
async function urlFor(props: { nodeId?: number | null; stackName?: string }): Promise<string> {
|
||||
render(<TerminalComponent deploySessionId="sess-1" {...props} />);
|
||||
await waitFor(() => expect(MockWS.instances.length).toBeGreaterThan(0));
|
||||
return MockWS.instances[0].url;
|
||||
}
|
||||
|
||||
describe('TerminalComponent WebSocket URL', () => {
|
||||
it('binds the generic stream to an explicit numeric nodeId, overriding the active node', async () => {
|
||||
const url = await urlFor({ nodeId: 7 });
|
||||
expect(url).toContain('/ws?nodeId=7');
|
||||
});
|
||||
|
||||
it('omits the nodeId query when nodeId is null (local), even with an active node set', async () => {
|
||||
const url = await urlFor({ nodeId: null });
|
||||
expect(url).toMatch(/\/ws$/);
|
||||
expect(url).not.toContain('nodeId=');
|
||||
});
|
||||
|
||||
it('falls back to the active node when no nodeId prop is given', async () => {
|
||||
const url = await urlFor({});
|
||||
expect(url).toContain('/ws?nodeId=9');
|
||||
});
|
||||
|
||||
it('binds the stack-logs stream to the captured nodeId', async () => {
|
||||
const url = await urlFor({ nodeId: 7, stackName: 'web' });
|
||||
expect(url).toContain('/api/stacks/web/logs?nodeId=7');
|
||||
});
|
||||
|
||||
it('omits the nodeId query on the stack-logs stream when nodeId is null', async () => {
|
||||
const url = await urlFor({ nodeId: null, stackName: 'web' });
|
||||
expect(url).toMatch(/\/api\/stacks\/web\/logs$/);
|
||||
expect(url).not.toContain('nodeId=');
|
||||
});
|
||||
|
||||
it('falls back to the active node on the stack-logs stream when no nodeId prop is given', async () => {
|
||||
const url = await urlFor({ stackName: 'web' });
|
||||
expect(url).toContain('/api/stacks/web/logs?nodeId=9');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user