SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,452 @@
|
||||
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { traceAuthenticatedWebuiMutation } from '../src/webui/mutation-trace';
|
||||
|
||||
const previousLogLevel = getLogLevel();
|
||||
|
||||
function mutationTrace(entries: LogEntry[]): LogEntry[] {
|
||||
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'WebUI.Mutation');
|
||||
}
|
||||
|
||||
async function waitForTerminal(entries: LogEntry[]): Promise<LogEntry[]> {
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
mutationTrace(entries).some((entry) =>
|
||||
entry.message.startsWith('webui_mutation_terminal '),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
return mutationTrace(entries);
|
||||
}
|
||||
|
||||
describe('traceAuthenticatedWebuiMutation', () => {
|
||||
afterEach(() => {
|
||||
setLogLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
it('traces a successful JSON write with complete request and response data', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const requestBody = '{"logLevel":"trace"}';
|
||||
const responseBody = '{"success":true}';
|
||||
const request = new Request('http://127.0.0.1/api/system/settings?source=test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer exact-token',
|
||||
'content-type': 'application/json',
|
||||
'x-request-detail': 'complete',
|
||||
},
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
request,
|
||||
async () =>
|
||||
new Response(responseBody, {
|
||||
status: 201,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-response-detail': 'complete',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.text()).toBe(responseBody);
|
||||
|
||||
const trace = await waitForTerminal(entries);
|
||||
expect(trace).toHaveLength(4);
|
||||
expect(trace[0]!.message).toContain('webui_mutation_start method="POST"');
|
||||
expect(trace[0]!.message).toContain(
|
||||
'url="http://127.0.0.1/api/system/settings?source=test"',
|
||||
);
|
||||
expect(trace[0]!.message).toContain('authorization');
|
||||
expect(trace[0]!.message).toContain('Bearer exact-token');
|
||||
expect(trace[0]!.message).toContain('x-request-detail');
|
||||
expect(trace[1]!.message).toContain('branch=request_body');
|
||||
expect(trace[1]!.message).toContain(`text=${JSON.stringify(requestBody)}`);
|
||||
expect(trace[1]!.message).toContain(`body=${Buffer.from(requestBody).toString('hex')}`);
|
||||
expect(trace[2]!.message).toContain('branch=response status=201');
|
||||
expect(trace[2]!.message).toContain('x-response-detail');
|
||||
expect(trace[2]!.message).toContain(`text=${JSON.stringify(responseBody)}`);
|
||||
expect(trace[2]!.message).toContain(
|
||||
`body=${Buffer.from(responseBody).toString('hex')}`,
|
||||
);
|
||||
expect(trace[3]!.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=completed reason=response_completed status=201 elapsedMs=\d+$/,
|
||||
);
|
||||
expect(trace.every((entry) => entry.req === trace[0]!.req)).toBe(true);
|
||||
expect(trace[0]!.req).toEqual(expect.any(Number));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('traces non-2xx and binary bodies without consuming the response', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const requestBytes = Uint8Array.from([0x00, 0xff, 0x80, 0x41]);
|
||||
const responseBytes = Uint8Array.from([0xde, 0xad, 0xbe, 0xef, 0x00]);
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/system/backup/import', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: requestBytes,
|
||||
}),
|
||||
async () =>
|
||||
new Response(responseBytes, {
|
||||
status: 422,
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(responseBytes);
|
||||
const trace = await waitForTerminal(entries);
|
||||
expect(
|
||||
trace.find((entry) => entry.message.includes('branch=request_body'))?.message,
|
||||
).toContain('length=4');
|
||||
expect(
|
||||
trace.find((entry) => entry.message.includes('branch=request_body'))?.message,
|
||||
).toContain('body=00ff8041');
|
||||
expect(
|
||||
trace.find((entry) => entry.message.includes('branch=response'))?.message,
|
||||
).toContain('body=deadbeef00');
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=failed reason=http_status status=422 elapsedMs=\d+$/,
|
||||
);
|
||||
expect(
|
||||
trace.filter((entry) => entry.message.startsWith('webui_mutation_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('traces a thrown handler with one failed terminal and preserves the error', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const failure = new Error('write exploded');
|
||||
|
||||
try {
|
||||
await expect(
|
||||
traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/global-config', {
|
||||
method: 'POST',
|
||||
body: 'complete request',
|
||||
}),
|
||||
async () => {
|
||||
throw failure;
|
||||
},
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
|
||||
const trace = mutationTrace(entries);
|
||||
expect(
|
||||
trace.some(
|
||||
(entry) =>
|
||||
entry.message.includes('branch=request_body') &&
|
||||
entry.message.includes('body=636f6d706c6574652072657175657374'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=failed reason=handler_threw error="write exploded" elapsedMs=\d+$/,
|
||||
);
|
||||
expect(
|
||||
trace.filter((entry) => entry.message.startsWith('webui_mutation_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies handler cancellation and timeout from their error provenance', async () => {
|
||||
setLogLevel('trace');
|
||||
|
||||
for (const [name, outcome, reason] of [
|
||||
['AbortError', 'cancelled', 'handler_cancelled'],
|
||||
['TimeoutError', 'timeout', 'handler_timeout'],
|
||||
] as const) {
|
||||
const entries: LogEntry[] = [];
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const failure = new DOMException(`${name} detail`, name);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/processes/42/load', { method: 'POST' }),
|
||||
async () => {
|
||||
throw failure;
|
||||
},
|
||||
),
|
||||
).rejects.toBe(failure);
|
||||
expect(mutationTrace(entries).at(-1)?.message).toMatch(
|
||||
new RegExp(
|
||||
`^webui_mutation_terminal outcome=${outcome} reason=${reason} error="${name} detail" elapsedMs=\\d+$`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('bypasses cloning and logging when TRACE is disabled', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('info');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const request = new Request('http://127.0.0.1/api/system/settings', {
|
||||
method: 'POST',
|
||||
body: 'must remain unread',
|
||||
});
|
||||
const clone = vi.spyOn(request, 'clone');
|
||||
const next = vi.fn(async () => new Response('ok'));
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(request, next);
|
||||
expect(await response.text()).toBe('ok');
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(entries).toEqual([]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not clone the streamed upload request body', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const request = new Request('http://127.0.0.1/api/debug/upload?filename=large.bin', {
|
||||
method: 'POST',
|
||||
body: Uint8Array.from([0x00, 0xff]),
|
||||
});
|
||||
const clone = vi.spyOn(request, 'clone');
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
request,
|
||||
async () =>
|
||||
new Response('{"status":"ok"}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
expect(await response.json()).toEqual({ status: 'ok' });
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
await waitForTerminal(entries);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an aborted streamed upload as cancelled even when the route returns 400', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const abort = new AbortController();
|
||||
const request = new Request('http://127.0.0.1/api/debug/upload?filename=cancelled.bin', {
|
||||
method: 'POST',
|
||||
body: Uint8Array.from([0x01]),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
abort.abort();
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
request,
|
||||
async () => new Response('{"status":"failed"}', { status: 400 }),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const trace = await waitForTerminal(entries);
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=cancelled reason=request_cancelled status=400 elapsedMs=\d+$/,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a non-abort streamed upload rejection as an HTTP failure', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const request = new Request('http://127.0.0.1/api/debug/upload?filename=too-large.bin', {
|
||||
method: 'POST',
|
||||
body: Uint8Array.from([0x01]),
|
||||
});
|
||||
|
||||
try {
|
||||
await traceAuthenticatedWebuiMutation(
|
||||
request,
|
||||
async () => new Response('{"status":"failed"}', { status: 400 }),
|
||||
);
|
||||
const trace = await waitForTerminal(entries);
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=failed reason=http_status status=400 elapsedMs=\d+$/,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not pull a streaming response ahead of the client', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
let pulls = 0;
|
||||
const stream = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(Uint8Array.from([pulls]));
|
||||
|
||||
if (pulls === 2) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/debug/invoke-stream', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
async () => new Response(stream),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(pulls).toBe(0);
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
|
||||
Uint8Array.from([0x01, 0x02]),
|
||||
);
|
||||
await waitForTerminal(entries);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a streaming response before its body completes and traces completion later', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
value.enqueue(Uint8Array.from([0x01, 0x02]));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/debug/invoke-stream', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
async () => new Response(stream, { status: 200 }),
|
||||
);
|
||||
|
||||
expect(response.body).not.toBeNull();
|
||||
expect(
|
||||
mutationTrace(entries).some((entry) =>
|
||||
entry.message.startsWith('webui_mutation_terminal '),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
controller.enqueue(Uint8Array.from([0x03, 0x04]));
|
||||
controller.close();
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
|
||||
Uint8Array.from([0x01, 0x02, 0x03, 0x04]),
|
||||
);
|
||||
|
||||
const trace = await waitForTerminal(entries);
|
||||
const responseChunks = trace.filter((entry) =>
|
||||
entry.message.includes('branch=response_chunk'),
|
||||
);
|
||||
expect(responseChunks.map((entry) => entry.message)).toEqual([
|
||||
'webui_mutation_branch branch=response_chunk offset=0 length=2 body=0102',
|
||||
'webui_mutation_branch branch=response_chunk offset=2 length=2 body=0304',
|
||||
]);
|
||||
expect(trace.every((entry) => entry.req === trace[0]!.req)).toBe(true);
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=completed reason=response_completed status=200 elapsedMs=\d+$/,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('records one failed terminal when the traced response body cannot be read', async () => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const streamFailure = new Error('response stream exploded');
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(Uint8Array.from([0xaa]));
|
||||
controller.error(streamFailure);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await traceAuthenticatedWebuiMutation(
|
||||
new Request('http://127.0.0.1/api/debug/invoke-stream', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
async () => new Response(stream),
|
||||
);
|
||||
await expect(response.arrayBuffer()).rejects.toBe(streamFailure);
|
||||
|
||||
const trace = await waitForTerminal(entries);
|
||||
expect(
|
||||
trace.some(
|
||||
(entry) =>
|
||||
entry.message.includes('branch=request_body') &&
|
||||
entry.message.includes('body=7b7d'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(trace.at(-1)?.message).toMatch(
|
||||
/^webui_mutation_terminal outcome=failed reason=body_read_failed status=200 error="response stream exploded" elapsedMs=\d+$/,
|
||||
);
|
||||
expect(
|
||||
trace.filter((entry) => entry.message.startsWith('webui_mutation_terminal ')),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['GET', '/api/config/12345'],
|
||||
['HEAD', '/api/config/12345'],
|
||||
['OPTIONS', '/api/config/12345'],
|
||||
['POST', '/api/auth/check-strength'],
|
||||
['POST', '/api/logs/level'],
|
||||
])('keeps %s %s outside mutation TRACE', async (method, pathname) => {
|
||||
const entries: LogEntry[] = [];
|
||||
setLogLevel('trace');
|
||||
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
|
||||
const request = new Request(`http://127.0.0.1${pathname}`, { method });
|
||||
const clone = vi.spyOn(request, 'clone');
|
||||
|
||||
try {
|
||||
await traceAuthenticatedWebuiMutation(
|
||||
request,
|
||||
async () => new Response(null, { status: 204 }),
|
||||
);
|
||||
expect(clone).not.toHaveBeenCalled();
|
||||
expect(mutationTrace(entries)).toEqual([]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user