Files
SnowLuma/packages/core/tests/webui-mutation-trace.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

453 lines
18 KiB
TypeScript

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();
}
});
});