mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
48cebf9501
* 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.
146 lines
6.0 KiB
TypeScript
146 lines
6.0 KiB
TypeScript
/**
|
|
* Unit tests for apiFetch's request shape, specifically the header merge.
|
|
*
|
|
* Regression guard: callers that pass a `headers` field must not lose the
|
|
* default Content-Type and x-node-id that apiFetch builds. An earlier shape
|
|
* spread fetchOptions over defaultOptions at the outer level after merging
|
|
* headers into defaultOptions.headers, which silently clobbered the merge
|
|
* when the caller supplied any `headers` value.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { apiFetch, DEPLOY_SESSION_HEADER, withDeploySession } from '../api';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
beforeEach(() => {
|
|
globalThis.fetch = vi.fn().mockResolvedValue(new Response('{}', {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
})) as unknown as typeof fetch;
|
|
try { localStorage.removeItem('sencho-active-node'); } catch { /* jsdom */ }
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function lastFetchInit(): RequestInit {
|
|
const calls = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls;
|
|
return calls[calls.length - 1][1] as RequestInit;
|
|
}
|
|
|
|
describe('apiFetch header merge', () => {
|
|
it('always sets Content-Type: application/json on the outgoing request', async () => {
|
|
await apiFetch('/health');
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
|
});
|
|
|
|
it('preserves Content-Type even when the caller supplies a custom header', async () => {
|
|
await apiFetch('/stacks/foo/files/content?path=app.txt', {
|
|
method: 'PUT',
|
|
headers: { 'If-Match': '"1700000000000"' },
|
|
body: JSON.stringify({ content: 'hi' }),
|
|
});
|
|
const init = lastFetchInit();
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers['Content-Type']).toBe('application/json');
|
|
expect(headers['If-Match']).toBe('"1700000000000"');
|
|
});
|
|
|
|
it('merges x-node-id when an active node is set even when caller supplies headers', async () => {
|
|
localStorage.setItem('sencho-active-node', '7');
|
|
await apiFetch('/stacks/foo/files', {
|
|
method: 'GET',
|
|
headers: { 'X-Trace-Id': 'abc' },
|
|
});
|
|
const init = lastFetchInit();
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers['x-node-id']).toBe('7');
|
|
expect(headers['Content-Type']).toBe('application/json');
|
|
expect(headers['X-Trace-Id']).toBe('abc');
|
|
localStorage.removeItem('sencho-active-node');
|
|
});
|
|
|
|
it('honours localOnly to skip x-node-id', async () => {
|
|
localStorage.setItem('sencho-active-node', '7');
|
|
await apiFetch('/stacks/foo/files', { localOnly: true, headers: { 'If-Match': '"1"' } });
|
|
const init = lastFetchInit();
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers['x-node-id']).toBeUndefined();
|
|
expect(headers['If-Match']).toBe('"1"');
|
|
expect(headers['Content-Type']).toBe('application/json');
|
|
localStorage.removeItem('sencho-active-node');
|
|
});
|
|
});
|
|
|
|
describe('apiFetch nodeId override', () => {
|
|
it('targets the explicit node, overriding a different active node', async () => {
|
|
localStorage.setItem('sencho-active-node', '7');
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3 });
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['x-node-id']).toBe('3');
|
|
localStorage.removeItem('sencho-active-node');
|
|
});
|
|
|
|
it('targets the local node (omits x-node-id) when nodeId is null, even with an active node set', async () => {
|
|
localStorage.setItem('sencho-active-node', '7');
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: null });
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['x-node-id']).toBeUndefined();
|
|
localStorage.removeItem('sencho-active-node');
|
|
});
|
|
|
|
it('falls back to the active node when nodeId is undefined', async () => {
|
|
localStorage.setItem('sencho-active-node', '7');
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST' });
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['x-node-id']).toBe('7');
|
|
localStorage.removeItem('sencho-active-node');
|
|
});
|
|
|
|
it('does not leak the nodeId option onto the outgoing fetch init', async () => {
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3 });
|
|
const init = lastFetchInit() as RequestInit & { nodeId?: unknown };
|
|
expect(init.nodeId).toBeUndefined();
|
|
});
|
|
|
|
it('keeps an explicit nodeId authoritative over a caller-supplied x-node-id header', async () => {
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: 3, headers: { 'x-node-id': '99' } });
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['x-node-id']).toBe('3');
|
|
});
|
|
|
|
it('drops a caller-supplied x-node-id when the explicit nodeId is null (local)', async () => {
|
|
await apiFetch('/stacks/foo/deploy', { method: 'POST', nodeId: null, headers: { 'x-node-id': '99' } });
|
|
const init = lastFetchInit();
|
|
expect((init.headers as Record<string, string>)['x-node-id']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('withDeploySession', () => {
|
|
it('uses the canonical header name (kept in sync with the backend)', () => {
|
|
expect(DEPLOY_SESSION_HEADER).toBe('x-deploy-session-id');
|
|
});
|
|
|
|
it('tags a bare POST with the session header', () => {
|
|
const opts = withDeploySession('sess-123', { method: 'POST' });
|
|
expect(opts.method).toBe('POST');
|
|
expect((opts.headers as Record<string, string>)[DEPLOY_SESSION_HEADER]).toBe('sess-123');
|
|
});
|
|
|
|
it('preserves caller body and headers without clobbering them', () => {
|
|
const opts = withDeploySession('sess-abc', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ a: 1 }),
|
|
headers: { 'X-Custom': 'keep' },
|
|
});
|
|
const headers = opts.headers as Record<string, string>;
|
|
expect(opts.method).toBe('POST');
|
|
expect(opts.body).toBe(JSON.stringify({ a: 1 }));
|
|
expect(headers['X-Custom']).toBe('keep');
|
|
expect(headers[DEPLOY_SESSION_HEADER]).toBe('sess-abc');
|
|
});
|
|
});
|