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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
actionErrorMessage,
publishActionResult,
scheduleActionNoticeDismiss,
} from '../src/contexts/ActionFeedbackContext';
describe('action feedback error details', () => {
it('preserves the real Error message', () => {
expect(actionErrorMessage(new Error('证书校验失败'))).toBe('证书校验失败');
});
it('preserves a non-empty string rejection', () => {
expect(actionErrorMessage('保存被拒绝')).toBe('保存被拒绝');
});
it('makes an unknown rejection observable', () => {
expect(actionErrorMessage({ code: 'E_UNKNOWN' })).toBe('未知错误');
});
});
describe('action feedback result publishing', () => {
it('publishes a superseded action result without replacing the active surface', () => {
const updateCurrentActivity = vi.fn();
const publishNotice = vi.fn();
publishActionResult(false, updateCurrentActivity, publishNotice);
expect(updateCurrentActivity).not.toHaveBeenCalled();
expect(publishNotice).toHaveBeenCalledOnce();
});
it('updates and publishes the current action result', () => {
const updateCurrentActivity = vi.fn();
const publishNotice = vi.fn();
publishActionResult(true, updateCurrentActivity, publishNotice);
expect(updateCurrentActivity).toHaveBeenCalledOnce();
expect(publishNotice).toHaveBeenCalledOnce();
});
});
describe('action feedback auto-dismiss', () => {
afterEach(() => {
vi.useRealTimers();
});
it('dismisses a completed notice after its visible countdown', () => {
vi.useFakeTimers();
const dismiss = vi.fn();
scheduleActionNoticeDismiss(dismiss);
vi.advanceTimersByTime(4_999);
expect(dismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(dismiss).toHaveBeenCalledOnce();
});
it('cancels dismissal when the notice unmounts early', () => {
vi.useFakeTimers();
const dismiss = vi.fn();
const cancel = scheduleActionNoticeDismiss(dismiss);
cancel();
vi.runAllTimers();
expect(dismiss).not.toHaveBeenCalled();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ParamField } from '../src/components/debug/param-field';
import { buildActionParams } from '../src/lib/action-params';
describe('debug action form parameters', () => {
const objectParam = { name: 'feed', type: 'object', required: true };
it('passes pasted objects with nested and unknown fields to the action', () => {
const feed = { identity: { id: 'opaque' }, future: [1, null, { enabled: true }] };
expect(buildActionParams([objectParam], { feed: JSON.stringify(feed, null, 2) })).toEqual({
feed,
});
});
it.each(['{bad', '[]', 'null', '1', 'false', '"text"'])(
'rejects invalid object input %s with the field name',
(feed) => {
expect(() => buildActionParams([objectParam], { feed })).toThrow(/feed/);
},
);
it('omits an untouched optional object rather than sending an empty string', () => {
expect(buildActionParams([{ ...objectParam, required: false }], { feed: '' })).toEqual({});
});
it('renders an object editor that accepts multiline JSON', () => {
const html = renderToStaticMarkup(
<ParamField
param={objectParam}
value={'{\n "id": "opaque"\n}'}
onChange={() => undefined}
uin=""
groupContext=""
actionName="get_qzone_public_feed_detail"
/>,
);
expect(html).toContain('<textarea');
expect(html).toContain('aria-label="feed"');
});
const contentParam = { name: 'content', type: 'string | object[]', required: true };
it.each(['[]', '[1,2]', '[{"type":0,"content":"hello"}]', ' \n中文 '])(
'preserves JSON-looking text %j when entered as a JSON string',
(content) => {
expect(buildActionParams([contentParam], { content: JSON.stringify(content) })).toEqual(
{ content },
);
},
);
it('passes a real rich-item array without converting it to OneBot segments', () => {
const content = [{ type: 0, content: 'hello', future: { keep: true } }];
expect(buildActionParams([contentParam], { content: JSON.stringify(content) })).toEqual({
content,
});
});
it.each(['[bad', '[1,2]', 'null', '1', 'false', '{}', '[null]', '[[]]'])(
'reports invalid rich content %s before invoking an action',
(content) => {
expect(() => buildActionParams([contentParam], { content })).toThrow(/content/);
},
);
it('renders rich content as an explicit JSON editor without the OneBot message builder', () => {
const html = renderToStaticMarkup(
<ParamField
param={contentParam}
value={'"[1,2]"'}
onChange={() => undefined}
uin=""
groupContext=""
actionName="comment_qzone_public_feed"
/>,
);
expect(html).toContain('<textarea');
expect(html).toContain('aria-label="content"');
expect(html).toContain('JSON 字符串');
expect(html).not.toContain('role="radiogroup"');
});
it('keeps existing scalar and OneBot message conversions', () => {
const fields = {
message_id: '-123',
enable: 'false',
message: '[{"type":"text","data":{"text":"hello"}}]',
text: '[1,2]',
};
expect(
buildActionParams(
[
{ name: 'message_id', type: 'messageId', required: true },
{ name: 'enable', type: 'bool', required: true },
{ name: 'message', type: 'message', required: true },
{ name: 'text', type: 'string', required: true },
],
fields,
),
).toEqual({
message_id: -123,
enable: false,
message: [{ type: 'text', data: { text: 'hello' } }],
text: '[1,2]',
});
});
});
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
import { normalizeOneBotConfig } from '../src/lib/onebot-config';
afterEach(() => vi.unstubAllGlobals());
describe('API client configuration', () => {
const config = normalizeOneBotConfig({});
const tokenStore = { load: () => 'token', save: vi.fn() };
it('keeps persisted configuration separate from failed runtime application', async () => {
const result = {
config,
saved: true,
applied: false,
online: true,
errors: [{ name: 'http', phase: 'start', message: 'port in use', at: 1 }],
message: '配置已保存,应用失败',
};
const fetchMock = vi.fn().mockResolvedValue(Response.json(result));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.config.save('12345', config)).resolves.toEqual(result);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('rejects a save response without configuration instead of refetching or returning the form', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ success: true, reloaded: true }))
.mockRejectedValue(new Error('refetch failed'));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.config.save('12345', config)).rejects.toThrow('无效的配置');
expect(fetchMock).toHaveBeenCalledOnce();
});
it('reads the configuration from the server envelope', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ config })));
const client = createApiClient({ tokenStore });
await expect(client.config.get('12345')).resolves.toEqual(config);
});
it('rejects a response without the configuration envelope', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(config)));
const client = createApiClient({ tokenStore });
await expect(client.config.get('12345')).rejects.toThrow('无效的配置');
});
});
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = { load: () => 'test-token', save: () => undefined };
afterEach(() => vi.unstubAllGlobals());
describe('API response JSON', () => {
it.each(['<html>proxy error</html>', '', 'null', '[]', '42'])(
'rejects an invalid successful response instead of returning an empty list (%j)',
async (body) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status: 200 })));
const client = createApiClient({ tokenStore });
await expect(client.processes.list()).rejects.toMatchObject({
status: 502,
code: 'INVALID_RESPONSE',
});
},
);
it('preserves the HTTP status when an error response is not JSON', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('<html>unavailable</html>', {
status: 503,
statusText: 'Service Unavailable',
}),
),
);
const client = createApiClient({ tokenStore });
await expect(client.processes.list()).rejects.toMatchObject({
status: 503,
message: 'Service Unavailable',
});
});
it('does not turn a failed JSON body read into successful empty data', async () => {
const failure = new Error('response stream interrupted');
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue({ ok: true, status: 200, json: () => Promise.reject(failure) }),
);
const client = createApiClient({ tokenStore });
await expect(client.processes.list()).rejects.toBe(failure);
});
});
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = {
load: () => 'trace-token',
save: () => undefined,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('API client full TRACE export', () => {
it('downloads the exact authenticated server text and filename', async () => {
const body = 'header\nTRACE recvHex=000102aabbcc\n';
const fetchMock = vi.fn().mockResolvedValue(
new Response(body, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename="server-trace.log"',
},
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.logs.exportTrace()).resolves.toEqual({
text: body,
filename: 'server-trace.log',
});
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
'/api/logs/export/trace',
expect.objectContaining({
headers: new Headers({ 'X-SnowLuma-Token': 'trace-token' }),
}),
);
});
it('rejects a failed export instead of downloading an error body', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'export unavailable' }), {
status: 500,
statusText: 'Internal Server Error',
}),
),
);
const client = createApiClient({ tokenStore });
await expect(client.logs.exportTrace()).rejects.toMatchObject({
status: 500,
message: 'export unavailable',
});
});
});
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const sessionToken = 'webui-session-token';
const proxyAuthorization = 'Basic cHJveHk6cGFzc3dvcmQ=';
const proxyHeaderEntries: [string, string][] = [['Authorization', proxyAuthorization]];
const tokenStore = { load: () => sessionToken, save: vi.fn() };
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('WebUI authentication behind a Basic-auth proxy', () => {
it('does not clear a new session when an old request returns 401', async () => {
const old = Promise.withResolvers<Response>();
vi.stubGlobal(
'fetch',
vi.fn((url: string) =>
url === '/api/login'
? Promise.resolve(Response.json({ token: 'new-token' }))
: old.promise,
),
);
const save = vi.fn();
const onUnauthorized = vi.fn();
const client = createApiClient({
tokenStore: { load: () => sessionToken, save },
onUnauthorized,
});
const request = client.request('/api/old');
await client.login('password');
old.resolve(new Response(null, { status: 401 }));
await request;
expect(save.mock.calls).toEqual([['new-token']]);
expect(onUnauthorized).not.toHaveBeenCalled();
});
it.each([
{ kind: 'object', headers: { Authorization: proxyAuthorization } },
{ kind: 'Headers', headers: new Headers({ Authorization: proxyAuthorization }) },
{ kind: 'tuples', headers: proxyHeaderEntries },
])('preserves supplied proxy credentials from $kind', async ({ headers }) => {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const sent = new Headers(init?.headers);
if (sent.get('authorization') !== proxyAuthorization) {
return new Response(null, { status: 401 });
}
return new Response(null, {
status: sent.get('x-snowluma-token') === sessionToken ? 204 : 403,
});
});
vi.stubGlobal('fetch', fetchMock);
const onUnauthorized = vi.fn();
const client = createApiClient({ tokenStore, onUnauthorized });
const response = await client.request('/api/auth/state', { headers });
expect(response.status).toBe(204);
expect(onUnauthorized).not.toHaveBeenCalled();
expect(tokenStore.save).not.toHaveBeenCalled();
});
it('leaves browser-managed proxy authentication available on JSON requests', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ list: [] }));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.qqList()).resolves.toEqual([]);
const headers = new Headers(fetchMock.mock.calls[0]![1]?.headers);
expect(headers.get('x-snowluma-token')).toBe(sessionToken);
expect(headers.has('authorization')).toBe(false);
});
it('authenticates multipart uploads without replacing the browser multipart boundary', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ config: {} }));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
const file = new File(['image'], 'background.png', { type: 'image/png' });
await client.ui.uploadBackground(file);
const [url, init] = fetchMock.mock.calls[0]!;
const headers = new Headers(init?.headers);
expect(url).toBe('/api/ui/background');
expect(headers.get('x-snowluma-token')).toBe(sessionToken);
expect(headers.has('authorization')).toBe(false);
expect(headers.has('content-type')).toBe(false);
expect(init?.body).toBeInstanceOf(FormData);
expect((init?.body as FormData).get('file')).toBe(file);
});
it('authenticates streamed action requests without overriding proxy authentication', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
new Response('data: {"type":"done"}\n\n', {
headers: { 'Content-Type': 'text/event-stream' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
const onFrame = vi.fn();
await client.debug.invokeStream('12345', 'test_action', {}, onFrame);
const [url, init] = fetchMock.mock.calls[0]!;
const headers = new Headers(init?.headers);
expect(url).toBe('/api/debug/invoke-stream');
expect(headers.get('x-snowluma-token')).toBe(sessionToken);
expect(headers.has('authorization')).toBe(false);
expect(headers.get('content-type')).toBe('application/json');
expect(onFrame).toHaveBeenCalledWith({ type: 'done' });
});
it('authenticates XHR uploads without overriding proxy authentication', async () => {
const headers = new Headers();
const send = vi.fn();
class UploadRequest {
upload = {};
responseType = '';
status = 200;
response = { path: '/tmp/uploaded-file', size: 4 };
onload?: () => void;
open = vi.fn();
setRequestHeader(name: string, value: string) {
headers.set(name, value);
}
send(file: File) {
send(file);
this.onload?.();
}
}
vi.stubGlobal('XMLHttpRequest', UploadRequest);
const client = createApiClient({ tokenStore });
const file = new File(['test'], 'test.txt');
await expect(client.debug.upload(file)).resolves.toEqual({
path: '/tmp/uploaded-file',
size: 4,
});
expect(send).toHaveBeenCalledWith(file);
expect(headers.get('x-snowluma-token')).toBe(sessionToken);
expect(headers.has('authorization')).toBe(false);
});
});
@@ -0,0 +1,278 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const encoder = new TextEncoder();
describe('API client SSE lifecycle', () => {
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('keeps repeated log subscriptions independent even when they reuse options', async () => {
let stream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(controller) {
stream = controller;
},
});
const fetchMock = vi.fn().mockResolvedValue(new Response(body));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const options = { onLine: vi.fn() };
const first = client.logs.stream(options);
const second = client.logs.stream(options);
first();
first();
try {
stream.enqueue(encoder.encode('data: {"message":"still subscribed"}\n\n'));
await vi.waitFor(() => expect(options.onLine).toHaveBeenCalledOnce());
expect(fetchMock).toHaveBeenCalledOnce();
} finally {
second();
}
});
it('fails a debug invocation on malformed stream data', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('data: broken\n\n')));
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
await expect(
client.debug.invokeStream('12345', 'test_action', {}, vi.fn()),
).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
});
it('propagates a debug callback failure and cancels the remaining body', async () => {
const cancel = vi.fn();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
},
cancel,
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body)));
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const error = new Error('callback failed');
await expect(
client.debug.invokeStream('12345', 'test_action', {}, () => {
throw error;
}),
).rejects.toBe(error);
expect(cancel).toHaveBeenCalledOnce();
expect(body.locked).toBe(false);
});
it('keeps only one connection when a restored page is also made visible', async () => {
const page = new EventTarget();
const document = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('window', page);
vi.stubGlobal('document', document);
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const dispose = client.stateStream({ onEvent: vi.fn() });
page.dispatchEvent(new Event('pagehide'));
document.dispatchEvent(new Event('visibilitychange'));
page.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
expect(fetchMock).toHaveBeenCalledTimes(2);
dispose();
});
it('keeps Authorization available for the proxy while authenticating and parsing frames', async () => {
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(stream, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({
tokenStore: { load: () => 'secret-session-token', save: vi.fn() },
});
const received = new Promise<unknown>((resolve) => {
const dispose = client.stateStream({
onEvent: (event) => {
resolve(event);
dispose();
},
});
});
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
streamController?.enqueue(encoder.encode('data: {"kind":"ready"}\n\n'));
await expect(received).resolves.toEqual({ kind: 'ready' });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/state/stream');
expect(new Headers(init.headers).get('x-snowluma-token')).toBe('secret-session-token');
expect(new Headers(init.headers).has('authorization')).toBe(false);
expect(init.signal?.aborted).toBe(true);
});
it('clears the session and stops reconnecting after an unauthorized response', async () => {
const page = new EventTarget();
const document = new EventTarget();
const removePageListener = vi.spyOn(page, 'removeEventListener');
const removeDocumentListener = vi.spyOn(document, 'removeEventListener');
vi.stubGlobal('window', page);
vi.stubGlobal('document', document);
const save = vi.fn();
const onUnauthorized = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }));
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({
tokenStore: { load: () => 'expired-token', save },
onUnauthorized,
});
const statuses: string[] = [];
client.stateStream({ onEvent: vi.fn(), onStatus: (status) => statuses.push(status) });
await vi.waitFor(() => expect(onUnauthorized).toHaveBeenCalledTimes(1));
expect(save).toHaveBeenCalledWith(null);
expect(statuses.at(-1)).toBe('closed');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(removePageListener).toHaveBeenCalledWith('pagehide', expect.any(Function));
expect(removePageListener).toHaveBeenCalledWith('pageshow', expect.any(Function));
expect(removeDocumentListener).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function),
);
expect(removeDocumentListener).toHaveBeenCalledWith('freeze', expect.any(Function));
});
it('stops delivering the current frame batch after disposal', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(
'data: {"kind":"ready"}\n\ndata: {"kind":"invalidate","resource":"qq-list"}\n\n',
),
),
);
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const onEvent = vi.fn(() => dispose());
const dispose = client.stateStream({ onEvent });
await vi.waitFor(() => expect(onEvent).toHaveBeenCalled());
expect(onEvent).toHaveBeenCalledOnce();
});
it('ignores a delayed unauthorized response from a disposed connection', async () => {
vi.useFakeTimers();
const response = Promise.withResolvers<Response>();
vi.stubGlobal(
'fetch',
vi.fn(() => response.promise),
);
const save = vi.fn();
const onUnauthorized = vi.fn();
const client = createApiClient({
tokenStore: { load: () => 'token', save },
onUnauthorized,
});
const dispose = client.stateStream({ onEvent: vi.fn() });
dispose();
response.resolve(new Response(null, { status: 401 }));
await vi.advanceTimersByTimeAsync(0);
expect(onUnauthorized).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it('clears the reconnect timer when disposed by the status callback', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('')));
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const dispose = client.stateStream({
onEvent: vi.fn(),
onStatus: (status) => {
if (status === 'reconnecting') {
dispose();
}
},
});
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(0);
});
it('suspends reconnects until the page returns and keeps one active request', async () => {
vi.useFakeTimers();
const page = new EventTarget();
vi.stubGlobal('window', page);
vi.stubGlobal('document', Object.assign(new EventTarget(), { visibilityState: 'visible' }));
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(''))
.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal!.addEventListener('abort', () => reject(init.signal!.reason), {
once: true,
});
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const onStatus = vi.fn();
const dispose = client.stateStream({ onEvent: vi.fn(), onStatus });
await vi.advanceTimersByTimeAsync(0);
expect(onStatus).toHaveBeenCalledWith('reconnecting');
page.dispatchEvent(new Event('pagehide'));
await vi.advanceTimersByTimeAsync(1000);
expect(fetchMock).toHaveBeenCalledOnce();
page.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
page.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
expect(fetchMock).toHaveBeenCalledTimes(2);
dispose();
});
it('reconnects after a completed stream without leaking the token', async () => {
vi.useFakeTimers();
try {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response('data: {"kind":"ready"}\r\n\r\n', { status: 200 }),
)
.mockImplementationOnce(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({
tokenStore: { load: () => 'reconnect-token', save: vi.fn() },
});
const onEvent = vi.fn();
const statuses: string[] = [];
const dispose = client.stateStream({
onEvent,
onStatus: (status) => statuses.push(status),
});
await vi.waitFor(() => expect(onEvent).toHaveBeenCalledWith({ kind: 'ready' }));
expect(statuses).toContain('reconnecting');
await vi.advanceTimersByTimeAsync(1_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls.every(([url]) => url === '/api/state/stream')).toBe(true);
dispose();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = {
load: () => 'test-token',
save: () => undefined,
};
describe('API client request deadline', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('aborts a pending request and reports a retryable timeout', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
}),
),
);
const client = createApiClient({ tokenStore });
const request = client.request('/api/processes');
const rejection = expect(request).rejects.toMatchObject({
message: '请求超时,请重试',
code: 'REQUEST_TIMEOUT',
});
await vi.advanceTimersByTimeAsync(30_000);
await rejection;
});
it('cancels a request without preventing a later retry', async () => {
const fetchMock = vi
.fn()
.mockImplementationOnce(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ list: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
const controller = new AbortController();
const cancelled = client.request('/api/processes', { signal: controller.signal });
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' });
await expect(
client.request('/api/processes').then((response) => response.json()),
).resolves.toEqual({ list: [] });
});
it('settles a pending login with a retryable timeout message', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
(_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
}),
),
);
const client = createApiClient({ tokenStore });
const login = client.login('password');
await vi.advanceTimersByTimeAsync(30_000);
await expect(login).resolves.toEqual({
ok: false,
message: '请求超时,请重试',
});
});
});
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = {
load: () => null as string | null,
save: vi.fn(),
};
afterEach(() => {
vi.unstubAllGlobals();
tokenStore.save.mockReset();
});
describe('login 2FA client', () => {
it('returns needsTotp without storing a session token', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
success: false,
needsTotp: true,
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
),
);
const client = createApiClient({ tokenStore });
await expect(client.login('Correct-Horse-1!')).resolves.toEqual({
ok: false,
needsTotp: true,
});
expect(tokenStore.save).not.toHaveBeenCalled();
});
it('sends the TOTP on the second login request and stores the session', async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
success: true,
token: 'session-token',
mustChangePassword: false,
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.login('Correct-Horse-1!', { totp: '287082' })).resolves.toEqual({
ok: true,
mustChangePassword: false,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/login',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ password: 'Correct-Horse-1!', totp: '287082' }),
}),
);
expect(tokenStore.save).toHaveBeenCalledWith('session-token');
});
});
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
class FakeUpload {
static instances: FakeUpload[] = [];
status = 200;
response: unknown = { status: 'ok', path: '/tmp/file', size: 3 };
responseType = '';
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
onabort: (() => void) | null = null;
sent = false;
constructor() {
FakeUpload.instances.push(this);
}
open() {}
setRequestHeader() {}
send() {
this.sent = true;
}
abort() {
if (this.sent) {
this.onabort?.();
}
}
}
beforeEach(() => {
FakeUpload.instances = [];
vi.stubGlobal('XMLHttpRequest', FakeUpload);
});
afterEach(() => vi.unstubAllGlobals());
describe('debug upload lifecycle', () => {
it('does not clear a new session when an old upload returns 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ token: 'new-token' })));
const save = vi.fn();
const onUnauthorized = vi.fn();
const client = createApiClient({
tokenStore: { load: () => 'old-token', save },
onUnauthorized,
});
const result = client.debug.upload(new File(['abc'], 'a.txt'));
const rejected = expect(result).rejects.toMatchObject({ status: 401 });
await client.login('password');
const upload = FakeUpload.instances[0]!;
upload.status = 401;
upload.onload?.();
await rejected;
expect(save.mock.calls).toEqual([['new-token']]);
expect(onUnauthorized).not.toHaveBeenCalled();
});
it('settles an already cancelled upload without sending a request', async () => {
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const signal = AbortSignal.abort();
const settled = vi.fn();
void client.debug.upload(new File(['abc'], 'a.txt'), { signal }).catch(settled);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(settled).toHaveBeenCalledWith(expect.objectContaining({ message: '上传已取消' }));
expect(FakeUpload.instances.every((upload) => !upload.sent)).toBe(true);
});
it.each(['load', 'error', 'abort'] as const)(
'releases abort and XHR handlers after %s',
async (event) => {
const client = createApiClient({ tokenStore: { load: () => 'token', save: vi.fn() } });
const controller = new AbortController();
const remove = vi.spyOn(controller.signal, 'removeEventListener');
const result = client.debug.upload(new File(['abc'], 'a.txt'), {
signal: controller.signal,
});
const settled = result.catch(() => undefined);
const upload = FakeUpload.instances[0]!;
upload[`on${event}`]?.();
await settled;
expect(remove).toHaveBeenCalledWith('abort', expect.any(Function));
expect(upload.onload).toBeNull();
expect(upload.onerror).toBeNull();
expect(upload.onabort).toBeNull();
expect(upload.upload.onprogress).toBeNull();
},
);
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
import { createAppResources } from '../src/lib/app-resources';
import type { QQInfo } from '../src/types';
function setup() {
const api = createApiClient({ tokenStore: { load: () => null, save: () => undefined } });
vi.spyOn(api, 'qqList').mockResolvedValue([]);
vi.spyOn(api.processes, 'list').mockResolvedValue([]);
vi.spyOn(api, 'connections').mockResolvedValue([]);
return { api, store: createAppResources(api) };
}
const accounts: QQInfo[] = [{ uin: '12345', nickname: '当前账号' }];
describe('app resources', () => {
it('publishes data and load status together, distinguishing an empty result from an initial load', async () => {
const { store } = setup();
const initial = store.getSnapshot();
expect(initial.qqList).toEqual({ data: [], ready: false, error: null });
const listener = vi.fn(() => {
expect(store.getSnapshot().qqList).toEqual({ data: [], ready: true, error: null });
});
const unsubscribe = store.subscribe(listener);
await store.refreshQqList();
expect(listener).toHaveBeenCalledOnce();
expect(initial.qqList.ready).toBe(false);
unsubscribe();
await store.refreshQqList();
expect(listener).toHaveBeenCalledOnce();
});
it('keeps the latest request when an older request completes later', async () => {
const { api, store } = setup();
const old = Promise.withResolvers<QQInfo[]>();
vi.mocked(api.qqList).mockReturnValueOnce(old.promise).mockResolvedValueOnce(accounts);
const first = store.refreshQqList();
await store.refreshQqList();
old.resolve([]);
await first;
expect(store.getSnapshot().qqList.data).toEqual(accounts);
});
it.each(['success', 'failure'])(
'keeps a pushed snapshot after a stale REST %s',
async (outcome) => {
const { api, store } = setup();
const request = Promise.withResolvers<QQInfo[]>();
vi.mocked(api.qqList).mockReturnValueOnce(request.promise);
const pending = store.refreshQqList();
store.onEvent({ resource: 'qq-list', data: accounts });
const snapshot = store.getSnapshot();
if (outcome === 'success') {
request.resolve([]);
} else {
request.reject(new Error('过期请求失败'));
}
await pending;
expect(store.getSnapshot()).toBe(snapshot);
expect(snapshot.qqList).toEqual({ data: accounts, ready: true, error: null });
},
);
it('does not invalidate a different resource when receiving a push', async () => {
const { api, store } = setup();
vi.mocked(api.qqList).mockResolvedValueOnce(accounts);
const pending = store.refreshQqList();
store.onEvent({ resource: 'connections', data: [] });
await pending;
expect(store.getSnapshot().qqList.data).toEqual(accounts);
expect(store.getSnapshot().connections.ready).toBe(true);
});
it('retains the last data on failure and clears the error on recovery', async () => {
const { api, store } = setup();
store.onEvent({ resource: 'qq-list', data: accounts });
vi.mocked(api.qqList).mockRejectedValueOnce(new Error('连接中断'));
await store.refreshQqList();
expect(store.getSnapshot().qqList).toEqual({
data: accounts,
ready: true,
error: '连接中断',
});
await store.refreshQqList();
expect(store.getSnapshot().qqList).toEqual({ data: [], ready: true, error: null });
});
it('invalidates pending work on cleanup and allows a new subscription cycle', async () => {
const { api, store } = setup();
const request = Promise.withResolvers<QQInfo[]>();
vi.mocked(api.qqList).mockReturnValueOnce(request.promise);
const pending = store.refreshQqList();
const initial = store.getSnapshot();
store.invalidate();
request.resolve(accounts);
await pending;
expect(store.getSnapshot()).toBe(initial);
await store.refreshQqList();
expect(store.getSnapshot().qqList.ready).toBe(true);
});
it('reconciles all stream resources when frames are dropped', async () => {
const { api, store } = setup();
store.onEvent({ kind: 'ready' });
expect(api.qqList).not.toHaveBeenCalled();
store.onEvent({ kind: 'dropped', count: 3 });
await vi.waitFor(() => expect(store.getSnapshot().connections.ready).toBe(true));
expect(api.qqList).toHaveBeenCalledOnce();
expect(api.processes.list).toHaveBeenCalledOnce();
expect(api.connections).toHaveBeenCalledOnce();
});
it('reports background update failures but rejects an explicit forced check', async () => {
const { api, store } = setup();
vi.spyOn(api.update, 'check').mockRejectedValue(new Error('检查失败'));
await expect(store.refreshUpdate()).resolves.toBeNull();
expect(store.getSnapshot().updateInfo.error).toBe('检查失败');
await expect(store.refreshUpdate(true)).rejects.toThrow('检查失败');
expect(api.update.check).toHaveBeenLastCalledWith(true);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { copyText } from '../src/lib/utils';
afterEach(() => vi.unstubAllGlobals());
describe('copyText', () => {
it('uses the clipboard API without creating a temporary element', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
vi.stubGlobal('document', undefined);
await expect(copyText('hello')).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('hello');
});
it.each(['success', 'rejected', 'focus throws', 'copy throws'])(
'removes the temporary element after legacy copy: %s',
async (outcome) => {
const children = new Set<unknown>();
const host = {
appendChild: (child: unknown) => children.add(child),
removeChild: (child: unknown) => children.delete(child),
};
const textarea = {
value: '',
style: {},
setAttribute: vi.fn(),
setSelectionRange: vi.fn(),
focus: () => {
if (outcome === 'focus throws') {
throw new Error('focus unavailable');
}
},
remove: () => children.delete(textarea),
};
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('permission denied')) },
});
vi.stubGlobal('document', {
body: host,
createElement: () => textarea,
querySelector: () => host,
execCommand: () => {
if (outcome === 'copy throws') {
throw new Error('copy unavailable');
}
return outcome === 'success';
},
});
await expect(copyText('hello')).resolves.toBe(outcome === 'success');
expect(children.size).toBe(0);
},
);
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { migrateOverviewBlocks } from '../src/lib/dashboard-layout';
describe('migrateOverviewBlocks', () => {
it('preserves saved widget configuration while restoring the grid', () => {
const migrated = migrateOverviewBlocks([
{
id: 'account',
visible: true,
x: 8,
y: 14,
w: 4,
h: 1,
config: { uin: '123456789' },
},
]);
expect(migrated.find((item) => item.id === 'account')).toEqual({
id: 'account',
visible: true,
x: 8,
y: 14,
w: 4,
h: 1,
config: { uin: '123456789' },
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createContactList } from '../src/lib/debug-contacts';
import type { DebugInvokeResult } from '../src/types';
function deferred() {
let resolve!: (result: DebugInvokeResult) => void;
const promise = new Promise<DebugInvokeResult>((done) => {
resolve = done;
});
return { promise, resolve };
}
const success = (nickname: string): DebugInvokeResult => ({
status: 'ok',
data: [{ user_id: 123, nickname }],
});
beforeEach(() => {
vi.stubGlobal('localStorage', { getItem: () => null, setItem: vi.fn() });
});
afterEach(() => vi.unstubAllGlobals());
describe('debug contact lists', () => {
it('keeps account state separate and ignores a response after leaving its list', async () => {
const previous = deferred();
const invoke = vi
.fn()
.mockReturnValueOnce(previous.promise)
.mockResolvedValue(success('current'));
const oldList = createContactList(invoke, 'friends', 'old-account');
const pending = oldList.refresh();
oldList.invalidate();
const current = createContactList(invoke, 'friends', 'new-account');
await current.refresh();
previous.resolve(success('stale'));
await pending;
expect(current.getSnapshot().items[0].label).toBe('current');
expect(oldList.getSnapshot().items).toEqual([]);
expect(createContactList(invoke, 'friends', 'old-account').getSnapshot().items).toEqual([]);
});
it('supersedes an in-flight refresh when using a fresh cached list', async () => {
const pending = deferred();
const invoke = vi
.fn()
.mockResolvedValueOnce(success('cached'))
.mockReturnValueOnce(pending.promise);
const list = createContactList(invoke, 'friends', 'cache-account');
await list.refresh();
const refresh = list.refresh();
await list.refresh(false);
expect(list.getSnapshot().loading).toBe(false);
pending.resolve(success('superseded'));
await refresh;
expect(list.getSnapshot().items[0].label).toBe('cached');
});
it('ignores an earlier refresh that completes after its replacement', async () => {
const first = deferred();
const invoke = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(success('latest'));
const list = createContactList(invoke, 'friends', 'race-account');
const pending = list.refresh();
await list.refresh();
first.resolve(success('old'));
await pending;
expect(list.getSnapshot().items[0].label).toBe('latest');
});
it('keeps group-member caches scoped to the account and group', async () => {
const invoke = vi.fn().mockResolvedValue(success('member'));
const first = createContactList(invoke, 'members', 'member-account', '111');
await first.refresh();
expect(invoke).toHaveBeenCalledWith('member-account', 'get_group_member_list', {
group_id: '111',
});
expect(
createContactList(invoke, 'members', 'member-account', '111').getSnapshot().items,
).toHaveLength(1);
expect(
createContactList(invoke, 'members', 'member-account', '222').getSnapshot().items,
).toEqual([]);
expect(
createContactList(invoke, 'members', 'other-account', '111').getSnapshot().items,
).toEqual([]);
});
it('reports malformed successful responses instead of caching an empty list', async () => {
const invoke = vi
.fn()
.mockResolvedValueOnce(success('retained'))
.mockResolvedValueOnce({ status: 'ok', data: {} });
const list = createContactList(invoke, 'friends', 'invalid-account');
await list.refresh();
await list.refresh();
expect(list.getSnapshot()).toMatchObject({ loading: false, error: '联系人列表格式无效' });
expect(list.getSnapshot().items[0].label).toBe('retained');
await list.refresh(false);
expect(list.getSnapshot().error).toBeNull();
expect(invoke).toHaveBeenCalledTimes(2);
});
it('does not request contacts without an account or group selection', async () => {
const invoke = vi.fn();
const lists = [
createContactList(invoke, 'friends', ''),
createContactList(invoke, 'members', 'account'),
];
for (const list of lists) {
await list.refresh();
expect(list.getSnapshot()).toEqual({ items: [], loading: false, error: null });
}
expect(invoke).not.toHaveBeenCalled();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { debugTasksReducer, type DebugTask } from '../src/lib/debug-tasks';
function running(id: string): DebugTask {
return { id, kind: 'upload', label: id, status: 'running', startedAt: 1, progress: 0 };
}
describe('debug task lifecycle', () => {
it('keeps the first terminal result when late progress or completion arrives', () => {
const finished = debugTasksReducer([running('a')], {
type: 'finish',
id: 'a',
status: 'canceled',
detail: 'Canceled',
endedAt: 2,
});
const updated = debugTasksReducer(finished, {
type: 'update',
id: 'a',
patch: { progress: 1 },
});
const completed = debugTasksReducer(updated, {
type: 'finish',
id: 'a',
status: 'done',
endedAt: 3,
});
expect(completed).toEqual(finished);
});
it('cannot dismiss running tasks or lose them while clearing history', () => {
const task = running('a');
expect(debugTasksReducer([task], { type: 'remove', id: 'a' })).toEqual([task]);
expect(debugTasksReducer([task], { type: 'clearFinished' })).toEqual([task]);
});
it('keeps every running task and trims history as they finish', () => {
let tasks: DebugTask[] = [];
for (let i = 0; i < 55; i++) {
tasks = debugTasksReducer(tasks, { type: 'start', task: running(String(i)) });
}
expect(tasks).toHaveLength(55);
for (let i = 0; i < 55; i++) {
tasks = debugTasksReducer(tasks, {
type: 'finish',
id: String(i),
status: 'done',
endedAt: 2,
});
}
expect(tasks).toHaveLength(50);
expect(tasks.every((task) => task.status === 'done')).toBe(true);
expect(tasks.some((task) => task.id === '0')).toBe(false);
});
it('releases cancellation and removes only completed tasks', () => {
const tasks = debugTasksReducer(
[{ ...running('a'), cancel: () => undefined }, running('b')],
{
type: 'finish',
id: 'a',
status: 'failed',
detail: 'Upload failed',
endedAt: 2,
},
);
expect(tasks[0]).toMatchObject({
status: 'failed',
detail: 'Upload failed',
endedAt: 2,
cancel: undefined,
});
expect(debugTasksReducer(tasks, { type: 'remove', id: 'a' })).toEqual([running('b')]);
});
});
@@ -0,0 +1,29 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
DEVELOPER_CRASH_MESSAGE,
DeveloperCrashProbe,
developerCrashReducer,
} from '../src/components/settings/developer-tools';
import { ErrorPage } from '../src/components/pages/status-screens';
import { SETTINGS_TABS } from '../src/router';
describe('developer settings tools', () => {
it('keeps the developer page in the public settings route contract', () => {
expect(SETTINGS_TABS).toContain('developer');
});
it('promotes a confirmed crash into the route error screen', () => {
const requested = developerCrashReducer(false, 'confirm');
expect(() =>
renderToStaticMarkup(<DeveloperCrashProbe requested={requested} />),
).toThrowError(DEVELOPER_CRASH_MESSAGE);
const html = renderToStaticMarkup(
<ErrorPage error={new Error(DEVELOPER_CRASH_MESSAGE)} reset={() => undefined} />,
);
expect(html).toContain('页面出错了');
expect(html).toContain('重试');
expect(html).toContain(DEVELOPER_CRASH_MESSAGE);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { IconMorph, iconMorphPresets } from '../src/components/interior/icon-morph';
describe('mobile menu icon morph', () => {
it('keeps the Interior menu and close geometry unchanged', () => {
expect(iconMorphPresets['menu-close'].shapes).toEqual([
{
rotate: 0,
d: ['M 4 7 L 20 7', 'M 4 12 L 20 12', 'M 4 17 L 20 17'],
},
{
rotate: 90,
d: ['M 6.5 6.5 L 17.5 17.5', 'M 12 12 L 12 12', 'M 6.5 17.5 L 17.5 6.5'],
},
]);
});
it('reflects the controlled sheet state in its accessible semantics', () => {
const menu = renderToStaticMarkup(
<IconMorph
preset="menu-close"
labels={['打开菜单', '关闭菜单']}
semantics="expanded"
active={false}
/>,
);
const close = renderToStaticMarkup(
<IconMorph
preset="menu-close"
labels={['打开菜单', '关闭菜单']}
semantics="expanded"
active
/>,
);
expect(menu).toContain('aria-label="打开菜单"');
expect(menu).toContain('aria-expanded="false"');
expect(close).toContain('aria-label="关闭菜单"');
expect(close).toContain('aria-expanded="true"');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { LoginPage } from '../src/components/pages/login-page';
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({
appearance: {
background: { type: 'none' },
reduceMotion: true,
disableMotion: true,
palette: 'default',
},
mode: 'light',
setMode: () => undefined,
}),
paletteResolved: () => false,
}));
describe('LoginPage 2FA step', () => {
it('keeps the password form on its own screen', () => {
const markup = renderToStaticMarkup(
<LoginPage onLogin={async () => ({ success: false, error: 'no' })} />,
);
expect(markup).toContain('输入访问令牌');
expect(markup).toContain('进入控制台');
expect(markup).toContain('max-w-md');
expect(markup).not.toContain('max-w-2xl');
expect(markup).not.toContain('character 1 of 6');
expect(markup).not.toContain('验证并进入');
});
it('shows a separate TOTP screen after the password factor succeeds', () => {
const markup = renderToStaticMarkup(
<LoginPage
initialPassword="Correct-Horse-1!"
initialNeedsTotp
onLogin={async () => ({ success: false, needsTotp: true })}
/>,
);
expect(markup).not.toContain('输入访问令牌');
expect(markup).toContain('输入 Authenticator 中的 6 位验证码');
expect(markup).not.toContain('验证并进入');
expect(markup).toContain('character 1 of 6');
expect(markup).toContain('可以把完整验证码粘贴到任意一格。');
expect(markup).toContain('使用恢复码');
expect(markup).toContain('返回');
expect(markup).toContain('max-w-md');
expect(markup).not.toContain('max-w-2xl');
expect(markup).not.toContain('h-[2px] w-3 rounded-full');
});
it('uses the same cell UI for a recovery code instead of a single field', () => {
const markup = renderToStaticMarkup(
<LoginPage
initialPassword="Correct-Horse-1!"
initialNeedsTotp
initialUseRecovery
onLogin={async () => ({ success: false, needsTotp: true })}
/>,
);
expect(markup).toContain('输入保存的一次性恢复码');
expect(markup).toContain('character 1 of 8');
expect(markup).toContain('character 8 of 8');
expect(markup).toContain('可以把完整恢复码粘贴到任意一格');
expect(markup).toContain('改用验证码');
expect(markup).toContain('max-w-2xl');
expect(markup).toContain('h-[2px] w-3 rounded-full');
expect(markup).not.toContain('XXXX-XXXX');
expect(markup).not.toContain('验证并进入');
expect(markup).not.toContain('输入访问令牌');
});
});
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
LOW_INTEGRITY_PERMANENT_KEY,
LOW_INTEGRITY_SESSION_KEY,
readLowIntegrityDismissed,
saveLowIntegrityDismissed,
} from '../src/lib/low-integrity-warning';
function storage() {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
};
}
let local = storage();
let session = storage();
beforeEach(() => {
local = storage();
session = storage();
vi.stubGlobal('window', { localStorage: local, sessionStorage: session });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('Low integrity warning', () => {
it('keeps temporary dismissal across reads/reloads but not a new tab session', () => {
expect(readLowIntegrityDismissed()).toBe(false);
saveLowIntegrityDismissed('session');
expect(session.getItem(LOW_INTEGRITY_SESSION_KEY)).toBe('1');
expect(local.getItem(LOW_INTEGRITY_PERMANENT_KEY)).toBeNull();
expect(readLowIntegrityDismissed()).toBe(true);
expect(readLowIntegrityDismissed()).toBe(true);
vi.stubGlobal('window', { localStorage: local, sessionStorage: storage() });
expect(readLowIntegrityDismissed()).toBe(false);
});
it('keeps permanent dismissal across new sessions', () => {
saveLowIntegrityDismissed('permanent');
vi.stubGlobal('window', { localStorage: local, sessionStorage: storage() });
expect(readLowIntegrityDismissed()).toBe(true);
expect(session.getItem(LOW_INTEGRITY_SESSION_KEY)).toBeNull();
});
it('honors session dismissal when local storage is blocked', () => {
saveLowIntegrityDismissed('session');
vi.stubGlobal('window', {
get localStorage() {
throw new Error('blocked');
},
sessionStorage: session,
});
expect(readLowIntegrityDismissed()).toBe(true);
expect(() => saveLowIntegrityDismissed('permanent')).toThrow('blocked');
});
it('does not claim to save permanent dismissal when storage writes fail', () => {
vi.spyOn(local, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
expect(() => saveLowIntegrityDismissed('permanent')).toThrow('quota');
expect(readLowIntegrityDismissed()).toBe(false);
});
it('can read in server rendering without accessing browser storage', () => {
vi.stubGlobal('window', undefined);
expect(readLowIntegrityDismissed()).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { messageBuilderSegmentKinds, newSeg } from '../src/components/debug/message-builder';
import { messageBuilderModeForAction } from '../src/components/debug/param-field';
describe('action message builder capabilities', () => {
it('does not offer forward nodes in ordinary message parameters', () => {
expect(messageBuilderSegmentKinds('message', [])).not.toContain('node');
});
it('only offers forward nodes at the root of forward actions', () => {
expect(messageBuilderSegmentKinds('forward', [])).toEqual(['node']);
});
it('locks nested forward content to nodes once a nested node is present', () => {
expect(messageBuilderSegmentKinds('node-content', [newSeg('node')])).toEqual(['node']);
});
it('keeps ordinary nested content free of nodes and window shakes', () => {
const kinds = messageBuilderSegmentKinds('node-content', [newSeg('text')]);
expect(kinds).not.toContain('node');
expect(kinds).not.toContain('poke');
});
it('selects the forward-only builder for every forward action', () => {
expect(messageBuilderModeForAction('send_forward_msg')).toBe('forward');
expect(messageBuilderModeForAction('send_group_forward_msg')).toBe('forward');
expect(messageBuilderModeForAction('send_private_forward_msg')).toBe('forward');
expect(messageBuilderModeForAction('upload_forward_msg')).toBe('forward');
expect(messageBuilderModeForAction('upload_foward_msg')).toBe('forward');
expect(messageBuilderModeForAction('send_msg')).toBe('message');
});
});
@@ -0,0 +1,122 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_EMAIL_BODY, DEFAULT_WEBHOOK_BODY } from '@snowluma/common/notification-templates';
import {
NotificationChannelDialog,
applyType,
toChannel,
} from '../src/components/settings/notification-channel-dialog';
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({ appearance: { disableMotion: true, reduceMotion: true } }),
}));
vi.mock('@/components/interior/modal', () => ({
Modal: ({
title,
description,
children,
footer,
}: {
title: string;
description?: string;
children?: unknown;
footer?: unknown;
}) => (
<div>
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
{children}
<div>{footer}</div>
</div>
),
}));
describe('NotificationChannelDialog', () => {
it('shows webhook fields for a new webhook channel', () => {
const markup = renderToStaticMarkup(
<NotificationChannelDialog
open
onOpenChange={() => undefined}
isEdit={false}
initial={{
id: 'channel-1',
name: '',
type: 'webhook',
url: '',
bodyTemplate: DEFAULT_WEBHOOK_BODY,
enabled: true,
}}
otherIds={[]}
onSubmit={() => undefined}
/>,
);
expect(markup).toContain('Webhook URL');
expect(markup).toContain('请求头');
expect(markup).not.toContain('SMTP 主机');
expect(markup).toContain('{status}');
expect(markup).toContain('{pid}');
expect(markup).toContain('{error}');
});
it('shows SMTP fields for an email channel', () => {
const markup = renderToStaticMarkup(
<NotificationChannelDialog
open
onOpenChange={() => undefined}
isEdit
initial={{
id: 'ops-mail',
name: '运维邮箱',
type: 'email',
url: '',
bodyTemplate: DEFAULT_EMAIL_BODY,
enabled: true,
smtpHost: 'smtp.qq.com',
smtpPort: 465,
smtpSecure: true,
from: 'bot@qq.com',
to: 'ops@example.com',
}}
otherIds={[]}
onSubmit={() => undefined}
/>,
);
expect(markup).toContain('SMTP 主机');
expect(markup).toContain('smtp.qq.com');
expect(markup).toContain('发件人');
expect(markup).toContain('收件人');
expect(markup).toContain('主题模板');
expect(markup).toContain('隐式 TLS');
expect(markup).not.toContain('Webhook URL');
expect(markup).toContain('{status}');
expect(markup).toContain('{pid}');
expect(markup).toContain('{error}');
});
it('includes process status when saving a new webhook or switching it to email', () => {
const webhook = {
id: 'new-channel',
name: 'New',
type: 'webhook' as const,
url: 'https://example.com/webhook',
enabled: true,
bodyTemplate: DEFAULT_WEBHOOK_BODY,
};
expect(toChannel(webhook, []).bodyTemplate).toContain('{status}');
expect(toChannel(applyType(webhook, 'email'), []).bodyTemplate).toContain('{status}');
});
it('preserves an existing custom template', () => {
const existing = {
id: 'existing',
name: 'Existing',
type: 'webhook' as const,
url: 'https://example.com/webhook',
enabled: true,
bodyTemplate: '{event}:{uin}',
};
expect(toChannel(existing, []).bodyTemplate).toBe('{event}:{uin}');
expect(toChannel(applyType(existing, 'email'), []).bodyTemplate).toBe('{event}:{uin}');
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_EMAIL_BODY,
DEFAULT_EMAIL_SUBJECT,
DEFAULT_WEBHOOK_BODY,
} from '@snowluma/common/notification-templates';
import { applyType, toChannel } from '../src/components/settings/notification-channel-dialog';
import { notificationChannelType, type NotificationChannel } from '../src/types';
function webhook(over: Partial<NotificationChannel> = {}): NotificationChannel {
return {
id: 'hook',
name: 'Hook',
type: 'webhook',
url: 'https://example.com/hook',
bodyTemplate: DEFAULT_WEBHOOK_BODY,
enabled: true,
...over,
};
}
describe('notificationChannelType', () => {
it('treats a missing type as webhook', () => {
expect(notificationChannelType({ type: undefined })).toBe('webhook');
expect(notificationChannelType({ type: 'email' })).toBe('email');
});
});
describe('applyType', () => {
it('swaps the default webhook body for the email body when switching', () => {
const next = applyType(webhook(), 'email');
expect(next.type).toBe('email');
expect(next.url).toBe('');
expect(next.bodyTemplate).toBe(DEFAULT_EMAIL_BODY);
expect(next.subjectTemplate).toBe(DEFAULT_EMAIL_SUBJECT);
expect(next.smtpPort).toBe(465);
expect(next.smtpSecure).toBe(true);
});
it('keeps a custom body when switching to email', () => {
const next = applyType(webhook({ bodyTemplate: 'custom {event}' }), 'email');
expect(next.bodyTemplate).toBe('custom {event}');
});
it('drops SMTP fields when switching back to webhook', () => {
const email = applyType(webhook(), 'email');
const back = applyType({ ...email, smtpPass: 'secret' }, 'webhook');
expect(back.type).toBe('webhook');
expect(back).not.toHaveProperty('smtpPass');
expect(back).not.toHaveProperty('smtpHost');
expect(back.bodyTemplate).toBe(DEFAULT_WEBHOOK_BODY);
});
});
describe('toChannel', () => {
it('emits a webhook without email fields and with trimmed headers', () => {
const ch = toChannel(webhook({ headers: { leftover: 'x' } }), [
{ name: ' Authorization ', value: ' Bearer a ' },
{ name: '', value: 'skip' },
]);
expect(ch).toEqual({
id: 'hook',
name: 'Hook',
type: 'webhook',
url: 'https://example.com/hook',
bodyTemplate: DEFAULT_WEBHOOK_BODY,
enabled: true,
headers: { Authorization: 'Bearer a' },
});
});
it('emits email fields and an empty url', () => {
const ch = toChannel(
{
id: ' mail ',
name: ' Ops ',
type: 'email',
url: 'https://ignored',
bodyTemplate: DEFAULT_EMAIL_BODY,
enabled: true,
smtpHost: ' smtp.qq.com ',
smtpPort: 587,
smtpSecure: false,
smtpUser: ' bot@qq.com ',
smtpPass: 'secret',
from: ' SnowLuma <bot@qq.com> ',
to: ' ops@example.com ',
subjectTemplate: ' {event} ',
},
[],
);
expect(ch).toEqual({
id: 'mail',
name: 'Ops',
type: 'email',
url: '',
bodyTemplate: DEFAULT_EMAIL_BODY,
enabled: true,
smtpHost: 'smtp.qq.com',
smtpPort: 587,
smtpSecure: false,
smtpUser: 'bot@qq.com',
smtpPass: 'secret',
from: 'SnowLuma <bot@qq.com>',
to: 'ops@example.com',
subjectTemplate: '{event}',
});
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { onboardingExecution } from '../src/lib/onboarding-actions';
describe('developer onboarding rehearsal', () => {
it('completes consent and password steps without writing persistent state', async () => {
const acceptAgreements = vi.fn(async () => ({ success: true }));
const changePassword = vi.fn(async () => ({ success: true }));
const execution = onboardingExecution(true, {
acceptAgreements,
changePassword,
});
expect(execution.passwordMode).toBe('rehearsal');
await expect(execution.actions.acceptAgreements()).resolves.toEqual({ success: true });
await expect(execution.actions.changePassword('old', 'new')).resolves.toEqual({
success: true,
});
expect(acceptAgreements).not.toHaveBeenCalled();
expect(changePassword).not.toHaveBeenCalled();
});
it('keeps real onboarding wired to persistent actions', async () => {
const acceptAgreements = vi.fn(async () => ({ success: true }));
const changePassword = vi.fn(async () => ({ success: true }));
const execution = onboardingExecution(false, {
acceptAgreements,
changePassword,
});
expect(execution.passwordMode).toBe('change');
await execution.actions.acceptAgreements();
await execution.actions.changePassword('old', 'new');
expect(acceptAgreements).toHaveBeenCalledOnce();
expect(changePassword).toHaveBeenCalledWith('old', 'new');
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
browserPath,
DEVELOPER_ONBOARDING_RETURN_KEY,
developerOnboardingReturnPath,
isDeveloperOnboardingLocation,
} from '../src/lib/onboarding-navigation';
describe('developer onboarding navigation', () => {
it('recognizes only the dedicated developer replay URL', () => {
expect(
isDeveloperOnboardingLocation({
pathname: '/onboarding',
search: '?mode=developer',
}),
).toBe(true);
expect(isDeveloperOnboardingLocation({ pathname: '/onboarding', search: '' })).toBe(false);
expect(
isDeveloperOnboardingLocation({
pathname: '/settings',
search: '?mode=developer',
}),
).toBe(false);
});
it('preserves the complete local return path', () => {
expect(
browserPath({
pathname: '/settings',
search: '?tab=developer',
hash: '#tools',
}),
).toBe('/settings?tab=developer#tools');
});
it('accepts only local non-onboarding return paths from history state', () => {
expect(
developerOnboardingReturnPath({
[DEVELOPER_ONBOARDING_RETURN_KEY]: '/settings?tab=developer',
}),
).toBe('/settings?tab=developer');
expect(
developerOnboardingReturnPath({
[DEVELOPER_ONBOARDING_RETURN_KEY]: 'https://example.com',
}),
).toBeNull();
expect(
developerOnboardingReturnPath({
[DEVELOPER_ONBOARDING_RETURN_KEY]: '//example.com',
}),
).toBeNull();
expect(
developerOnboardingReturnPath({
[DEVELOPER_ONBOARDING_RETURN_KEY]: '/onboarding?mode=developer',
}),
).toBeNull();
});
});
@@ -0,0 +1,74 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { WizardSteps } from '../src/components/interior/wizard-steps';
import {
advanceOnboardingStep,
buildRequiredOnboardingStepIds,
} from '../src/components/pages/onboarding-steps';
describe('buildRequiredOnboardingStepIds', () => {
it('keeps legal consent before password setup when both are required', () => {
expect(
buildRequiredOnboardingStepIds({
needsConsent: true,
mustChangePassword: true,
}),
).toEqual(['agreements', 'password']);
});
it('omits steps already satisfied by backend environment-aware policy', () => {
expect(
buildRequiredOnboardingStepIds({
needsConsent: false,
mustChangePassword: false,
}),
).toEqual([]);
expect(
buildRequiredOnboardingStepIds({
needsConsent: false,
mustChangePassword: true,
}),
).toEqual(['password']);
});
it('completes consent and password in order before releasing onboarding', () => {
const steps = buildRequiredOnboardingStepIds({
needsConsent: true,
mustChangePassword: true,
});
expect(advanceOnboardingStep(steps, 'agreements')).toEqual({
nextIndex: 1,
complete: false,
});
expect(advanceOnboardingStep(steps, 'password')).toEqual({
nextIndex: null,
complete: true,
});
});
});
describe('WizardSteps onboarding policy', () => {
it('only renders a skip action for a step configured as skippable', () => {
const skippable = renderToStaticMarkup(
<WizardSteps
steps={[
{ id: 'optional', label: '可选步骤', content: <p>可选内容</p>, canSkip: true },
{ id: 'required', label: '必要步骤', content: <p>必要内容</p> },
]}
skipLabel="跳过"
/>,
);
const required = renderToStaticMarkup(
<WizardSteps
steps={[
{ id: 'required', label: '必要步骤', content: <p>必要内容</p>, canSkip: false },
]}
skipLabel="跳过"
/>,
);
expect(skippable).toContain('跳过');
expect(required).not.toContain('跳过');
});
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { accessTokenFeedback } from '../src/lib/access-token-feedback';
import { NETWORK_TABS, generateAccessToken } from '../src/components/config/defaults';
describe('OneBot access token editor policy', () => {
it('creates inbound endpoints on loopback with generated tokens', () => {
const http = NETWORK_TABS.httpServers.defaultEntry(1);
const ws = NETWORK_TABS.wsServers.defaultEntry(1);
expect(http.host).toBe('127.0.0.1');
expect(ws.host).toBe('127.0.0.1');
expect(http.accessToken).not.toBe(ws.accessToken);
expect(accessTokenFeedback(http.accessToken ?? '', [], false).valid).toBe(true);
expect(accessTokenFeedback(ws.accessToken ?? '', [], false).valid).toBe(true);
});
it('generates a fresh strong token on demand', () => {
const first = generateAccessToken();
const second = generateAccessToken();
expect(first).not.toBe(second);
expect(accessTokenFeedback(first, ['SnowLuma', 'OneBot'], false).valid).toBe(true);
});
it('gives one actionable message for short and guessable values', () => {
expect(accessTokenFeedback('short', ['SnowLuma'], false)).toEqual({
valid: false,
tone: 'error',
message: '令牌至少需要 16 个字符,请继续补充或重新生成。',
});
expect(accessTokenFeedback('SnowLumaSnowLuma', ['SnowLuma'], false)).toEqual({
valid: false,
tone: 'error',
message: '令牌容易被猜中,请使用右侧按钮生成新的随机令牌。',
});
});
it('only permits an empty inbound token in a trusted bind or local-page context', () => {
expect(accessTokenFeedback('', [], false)).toEqual({
valid: false,
tone: 'error',
message: '未绑定本机地址时,远程访问必须填写令牌;请生成令牌或将主机改为 127.0.0.1。',
});
expect(accessTokenFeedback('', [], true)).toMatchObject({ valid: true, tone: 'warning' });
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { normalizeOneBotConfig } from '../src/lib/onebot-config';
describe('normalizeOneBotConfig history sync', () => {
it('defaults older payloads to disabled', () => {
expect(normalizeOneBotConfig({}).historySync).toEqual({ enabled: false });
});
it('preserves an explicit per-account opt-in', () => {
expect(
normalizeOneBotConfig({
historySync: { enabled: true },
}).historySync,
).toEqual({ enabled: true });
});
});
+31
View File
@@ -0,0 +1,31 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { OtpInput } from '../src/components/interior/otp-input';
describe('OtpInput official error/hint contract', () => {
it('shows the hint while error is false', () => {
const markup = renderToStaticMarkup(
<OtpInput
label="验证码"
hint="可以把完整验证码粘贴到任意一格。"
error={false}
errorMessage="验证码不正确,改错的那一位即可。"
/>,
);
expect(markup).toContain('可以把完整验证码粘贴到任意一格。');
expect(markup).not.toContain('验证码不正确,改错的那一位即可。');
});
it('swaps to the error message when error is true', () => {
const markup = renderToStaticMarkup(
<OtpInput
label="验证码"
hint="可以把完整验证码粘贴到任意一格。"
error
errorMessage="验证码不正确,改错的那一位即可。"
/>,
);
expect(markup).toContain('验证码不正确,改错的那一位即可。');
expect(markup).toContain('aria-invalid="true"');
});
});
@@ -0,0 +1,127 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { FloatingLabelInput } from '../src/components/interior/floating-label';
import { InlineValidation } from '../src/components/interior/inline-validation';
import { PasswordStrength, type PasswordRule } from '../src/components/interior/password-strength';
import { passwordRequirementHint } from '../src/components/pages/change-password-form';
const RULES: readonly PasswordRule[] = [
{ id: 'length', label: '长度不少于 10 位', test: (value) => value.length >= 10 },
{ id: 'upper', label: '包含大写字母', test: (value) => /[A-Z]/.test(value) },
];
describe('password input guidance', () => {
it('renders an accessible floating password label', () => {
const markup = renderToStaticMarkup(
<FloatingLabelInput
id="password"
label="新密码"
type="password"
value=""
required
onChange={() => {}}
/>,
);
expect(markup).toContain('id="password"');
expect(markup).toContain('for="password"');
expect(markup).toContain('新密码');
expect(markup).toContain('aria-required="true"');
});
it('keeps the strength details out of the tree while hidden', () => {
const markup = renderToStaticMarkup(
<PasswordStrength value="SnowLuma-Test" rules={RULES} visible={false} />,
);
expect(markup).not.toContain('role="meter"');
expect(markup).not.toContain('长度不少于 10 位');
});
it('shows the evaluated strength details while visible', () => {
const markup = renderToStaticMarkup(
<PasswordStrength value="SnowLuma-Test" rules={RULES} visible />,
);
expect(markup).toContain('role="meter"');
expect(markup).toContain('aria-valuenow="2"');
expect(markup).toContain('长度不少于 10 位');
expect(markup).toContain('包含大写字母');
});
it('renders inline validation with its hint and status channel', () => {
const markup = renderToStaticMarkup(
<InlineValidation
id="confirm-password"
label="确认新密码"
value=""
onChange={() => {}}
validate={(value) => (value ? null : '请再次输入新密码')}
hint="需要与新密码完全一致"
/>,
);
expect(markup).toContain('确认新密码');
expect(markup).toContain('需要与新密码完全一致');
expect(markup).toContain('role="status"');
});
});
describe('passwordRequirementHint', () => {
const rules = [
{ id: 'length', label: '长度不少于 10 位', ok: false },
{ id: 'upper', label: '包含大写字母', ok: true },
];
it('hides unmet-rule copy while the animated list is open', () => {
expect(
passwordRequirementHint({
focused: true,
value: 'short',
checked: true,
valid: false,
rules,
checkError: '',
}),
).toBeNull();
});
it('lists unmet rules after the new-password field loses focus', () => {
expect(
passwordRequirementHint({
focused: false,
value: 'short',
checked: true,
valid: false,
rules,
checkError: '',
}),
).toBe('仍需满足:长度不少于 10 位');
});
it('does not invent unmet rules while strength is still being checked', () => {
expect(
passwordRequirementHint({
focused: false,
value: 'short',
checked: false,
valid: false,
rules,
checkError: '',
}),
).toBeNull();
});
it('surfaces a failed strength check after blur', () => {
expect(
passwordRequirementHint({
focused: false,
value: 'short',
checked: false,
valid: false,
rules,
checkError: '网络错误',
}),
).toBe('密码强度校验失败:网络错误');
});
});
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = {
load: () => 'test-token',
save: () => undefined,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('password strength API client', () => {
it('returns the server-owned rule evaluation', async () => {
const rules = [
{ id: 'length', label: '长度不少于 10 位', ok: true },
{ id: 'no-space', label: '不包含空格', ok: true },
];
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
rules,
valid: true,
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.checkPasswordStrength('Strong-password')).resolves.toEqual({
rules,
valid: true,
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/auth/check-strength',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ password: 'Strong-password' }),
headers: new Headers({
'X-SnowLuma-Token': 'test-token',
'Content-Type': 'application/json',
}),
signal: expect.any(AbortSignal),
}),
);
});
it('rejects malformed responses instead of silently disabling the form', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
valid: false,
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
),
);
const client = createApiClient({ tokenStore });
await expect(client.checkPasswordStrength('candidate')).rejects.toMatchObject({
status: 502,
message: '密码强度接口返回了无效响应',
});
});
it('propagates request failures for observable inline validation', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
message: 'strength service unavailable',
}),
{
status: 503,
headers: { 'content-type': 'application/json' },
},
),
),
);
const client = createApiClient({ tokenStore });
await expect(client.checkPasswordStrength('candidate')).rejects.toMatchObject({
status: 503,
message: 'strength service unavailable',
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { startPolling } from '../src/lib/polling';
let page: EventTarget & { hidden: boolean };
beforeEach(() => {
vi.useFakeTimers();
page = Object.assign(new EventTarget(), { hidden: false });
vi.stubGlobal('document', page);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('resource polling', () => {
it('reports a failed refresh and retries without leaving a second timer', async () => {
const failure = new Error('refresh failed');
const report = vi.spyOn(console, 'error').mockImplementation(() => {});
const refresh = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined);
const stop = startPolling(refresh, 1000);
await vi.advanceTimersByTimeAsync(0);
expect(report).toHaveBeenCalledExactlyOnceWith('polling failed', failure);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(1000);
expect(refresh).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(1);
stop();
});
it('does not keep a timer while hidden or waiting for a response', async () => {
page.hidden = true;
const pending = Promise.withResolvers<void>();
const refresh = vi.fn(() => pending.promise);
const stop = startPolling(refresh, 1000);
expect(vi.getTimerCount()).toBe(0);
page.hidden = false;
page.dispatchEvent(new Event('visibilitychange'));
expect(refresh).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
pending.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);
page.hidden = true;
page.dispatchEvent(new Event('visibilitychange'));
expect(vi.getTimerCount()).toBe(0);
stop();
});
it('does not reschedule a request that finishes after disposal', async () => {
const pending = Promise.withResolvers<void>();
const refresh = vi.fn(() => pending.promise);
const stop = startPolling(refresh, 1000);
stop();
pending.resolve();
await vi.advanceTimersByTimeAsync(10_000);
expect(refresh).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('waits a full interval after a slow response before polling again', async () => {
const pending = Promise.withResolvers<void>();
const refresh = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(undefined);
const stop = startPolling(refresh, 1000);
await vi.advanceTimersByTimeAsync(1500);
pending.resolve();
await vi.advanceTimersByTimeAsync(999);
expect(refresh).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1);
expect(refresh).toHaveBeenCalledTimes(2);
stop();
});
it('starts immediately and skips ticks while a request is pending', async () => {
const pending = Promise.withResolvers<void>();
const refresh = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(undefined);
const stop = startPolling(refresh, 1000);
expect(refresh).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(3000);
expect(refresh).toHaveBeenCalledOnce();
pending.resolve();
await vi.advanceTimersByTimeAsync(1000);
expect(refresh).toHaveBeenCalledTimes(2);
stop();
});
it('pauses in the background and refreshes when the page becomes visible', async () => {
page.hidden = true;
const refresh = vi.fn().mockResolvedValue(undefined);
const stop = startPolling(refresh, 1000);
await vi.advanceTimersByTimeAsync(2000);
expect(refresh).not.toHaveBeenCalled();
page.hidden = false;
page.dispatchEvent(new Event('visibilitychange'));
expect(refresh).toHaveBeenCalledOnce();
stop();
await vi.advanceTimersByTimeAsync(2000);
page.dispatchEvent(new Event('visibilitychange'));
expect(refresh).toHaveBeenCalledOnce();
});
it('keeps initial and visibility refreshes when periodic polling is disabled', async () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const stop = startPolling(refresh, 0);
await vi.advanceTimersByTimeAsync(60_000);
expect(refresh).toHaveBeenCalledOnce();
page.dispatchEvent(new Event('visibilitychange'));
expect(refresh).toHaveBeenCalledTimes(2);
stop();
});
it('keeps the update-check cadence independent of page visibility', async () => {
page.hidden = true;
const refresh = vi.fn().mockResolvedValue(undefined);
const stop = startPolling(refresh, 6000, false);
await vi.advanceTimersByTimeAsync(6000);
expect(refresh).toHaveBeenCalledTimes(2);
page.hidden = false;
page.dispatchEvent(new Event('visibilitychange'));
expect(refresh).toHaveBeenCalledTimes(2);
stop();
});
});
@@ -0,0 +1,76 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { ProcessesPage } from '../src/components/pages/processes-page';
import type { HookProcessInfo } from '../src/types';
const state = vi.hoisted(() => ({ processList: [] as HookProcessInfo[] }));
vi.mock('@/contexts/AppStateContext', () => ({
useAppState: () => ({
processList: state.processList,
processOps: { statusOf: () => null, banner: null },
refreshProcesses: vi.fn(),
resources: { processList: { ready: true, error: '' } },
}),
}));
vi.mock('@/contexts/LayoutContext', () => ({
useLayout: () => ({ pages: { processesSort: 'pid' }, setPages: vi.fn() }),
}));
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({ appearance: { disableMotion: true } }),
}));
vi.mock('@/components/confirm-dialog', () => ({ ConfirmDialog: () => null }));
function renderProcess(overrides: Partial<HookProcessInfo>) {
state.processList = [
{
pid: 4242,
name: 'qq',
path: '',
injected: false,
connected: false,
loggedIn: false,
uin: '0',
status: 'available',
error: '',
method: '',
account: null,
...overrides,
},
];
return renderToStaticMarkup(<ProcessesPage />);
}
describe('process account labels', () => {
it('does not label an unidentified process as signed out', () => {
const markup = renderProcess({});
expect(markup).toContain('PID 4242');
expect(markup).not.toContain('未登录');
expect(markup).not.toContain('/avatar/');
});
it('shows the locally detected identity before hook login', () => {
const markup = renderProcess({ account: { uin: '10001', nickname: 'Alice' } });
expect(markup).toContain('Alice');
expect(markup).toContain('/avatar/10001');
expect(markup).toContain('QQ 10001');
});
it('uses native login identity without an account database', () => {
const markup = renderProcess({ loggedIn: true, uin: '10002', status: 'online' });
expect(markup).toContain('/avatar/10002');
expect(markup).toContain('已在线');
});
it('does not apply a stale local nickname to a newly logged-in account', () => {
const markup = renderProcess({
loggedIn: true,
uin: '10002',
status: 'online',
account: { uin: '10001', nickname: 'Alice' },
});
expect(markup).toContain('/avatar/10002');
expect(markup).not.toContain('Alice');
expect(markup).not.toContain('10001');
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { selectServerLogLevel, TRACE_CONFIRMATION_WARNINGS } from '../src/lib/server-log-level';
describe('selectServerLogLevel', () => {
it('keeps every required warning in the TRACE confirmation', () => {
expect(TRACE_CONFIRMATION_WARNINGS).toEqual([
'TRACE 会产生非常大量的数据。',
'TRACE 可能包含未经脱敏的隐私数据和凭据。',
'TRACE 不适合长时间开启。',
'TRACE 仅用于小规模问题复现和 bug 反馈。',
]);
});
it('requires confirmation before entering TRACE from a normal level', () => {
const applyLevel = vi.fn();
const requestTraceConfirmation = vi.fn();
selectServerLogLevel({
currentLevel: 'info',
nextLevel: 'trace',
applyLevel,
requestTraceConfirmation,
});
expect(requestTraceConfirmation).toHaveBeenCalledOnce();
expect(applyLevel).not.toHaveBeenCalled();
});
it('applies ordinary level transitions immediately', () => {
const applyLevel = vi.fn();
const requestTraceConfirmation = vi.fn();
selectServerLogLevel({
currentLevel: 'trace',
nextLevel: 'info',
applyLevel,
requestTraceConfirmation,
});
expect(applyLevel).toHaveBeenCalledExactlyOnceWith('info');
expect(requestTraceConfirmation).not.toHaveBeenCalled();
});
it('requires confirmation each time TRACE is re-entered', () => {
const applyLevel = vi.fn();
const requestTraceConfirmation = vi.fn();
selectServerLogLevel({
currentLevel: 'debug',
nextLevel: 'trace',
applyLevel,
requestTraceConfirmation,
});
selectServerLogLevel({
currentLevel: 'info',
nextLevel: 'trace',
applyLevel,
requestTraceConfirmation,
});
expect(requestTraceConfirmation).toHaveBeenCalledTimes(2);
expect(applyLevel).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,30 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { SkeletonSwap } from '../src/components/interior/skeleton-swap';
describe('SkeletonSwap', () => {
it('reserves the requested space without painting a skeleton immediately', () => {
const html = renderToStaticMarkup(
<SkeletonSwap ready={false} reserve={84} lines={4} label="测试内容">
<div>尚未就绪</div>
</SkeletonSwap>,
);
expect(html).toContain('aria-busy="true"');
expect(html).toContain('height:84px');
expect(html).toContain('尚未就绪');
expect(html).not.toContain('aria-hidden="true"');
});
it('exposes ready content without a loading announcement', () => {
const html = renderToStaticMarkup(
<SkeletonSwap ready label="测试内容">
<div>内容已加载</div>
</SkeletonSwap>,
);
expect(html).toContain('aria-busy="false"');
expect(html).toContain('内容已加载');
expect(html).toContain('测试内容 loaded');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { readSseJson } from '../src/lib/api/sse';
const encoder = new TextEncoder();
async function read(chunks: Uint8Array[]): Promise<unknown[]> {
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
const frames: unknown[] = [];
for await (const frame of readSseJson(body)) {
frames.push(frame);
}
return frames;
}
describe('SSE JSON reader', () => {
it.each(['\n', '\r\n', '\r'])('handles fragmented UTF-8 and %j line endings', async (eol) => {
const bytes = encoder.encode(
`: heartbeat${eol}data: {"text":${eol}data: "中文"}${eol}${eol}`,
);
expect(await read(Array.from(bytes, (byte) => Uint8Array.of(byte)))).toEqual([
{ text: '中文' },
]);
});
it('discards an incomplete event at EOF', async () => {
expect(await read([encoder.encode('data: {"unfinished":true}\n')])).toEqual([]);
});
it('limits individual frames rather than the size of a network chunk', async () => {
const frame = 'data: ' + JSON.stringify({ text: 'x'.repeat(600_000) }) + '\n\n';
expect(await read([encoder.encode(frame.repeat(2))])).toHaveLength(2);
await expect(
read([encoder.encode('data: ' + 'x'.repeat(1024 * 1024))]),
).rejects.toMatchObject({ code: 'INVALID_RESPONSE' });
});
it('reports malformed JSON instead of treating the operation as successful', async () => {
await expect(read([encoder.encode('data: broken\n\n')])).rejects.toMatchObject({
code: 'INVALID_RESPONSE',
});
});
it('cancels a pending read and releases the lock on abort', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
cancel() {
cancelled = true;
},
});
const controller = new AbortController();
const reader = readSseJson(body, controller.signal);
const result = reader.next();
controller.abort();
await expect(result).rejects.toMatchObject({ name: 'AbortError' });
expect(cancelled).toBe(true);
expect(body.locked).toBe(false);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
const tokenStore = {
load: () => 'test-token',
save: () => undefined,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('storage API client', () => {
it('reads the authenticated storage overview', async () => {
const payload = {
settings: {
saved: { logMaxTotalMb: 1024, logRetainDays: 7, logPerUin: false },
effective: { logMaxTotalMb: 1024, logRetainDays: 7, logPerUin: false },
envOverrides: [],
},
snapshot: {
logs: {
state: 'healthy',
totalBytes: 0,
maxTotalBytes: 1024 * 1024 * 1024,
retainDays: 7,
perUinEnabled: false,
fileCount: 1,
activeFileCount: 1,
droppedLines: 0,
},
temporary: { totalBytes: 0, fileCount: 0, activeItemCount: 0 },
accounts: [],
totals: { logsBytes: 0, temporaryBytes: 0, accountDataBytes: 0, managedBytes: 0 },
},
lastCleanup: null,
};
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await expect(client.storage.get()).resolves.toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/api/system/storage',
expect.objectContaining({
headers: new Headers({ 'X-SnowLuma-Token': 'test-token' }),
signal: expect.any(AbortSignal),
}),
);
});
it('posts a partial log-settings patch', async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
success: true,
settings: {
saved: { logMaxTotalMb: 1024, logRetainDays: 0, logPerUin: false },
effective: { logMaxTotalMb: 1024, logRetainDays: 0, logPerUin: false },
envOverrides: [],
},
status: { state: 'healthy' },
snapshot: {},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const client = createApiClient({ tokenStore });
await client.storage.saveSettings({ logRetainDays: 0 });
expect(fetchMock).toHaveBeenCalledWith(
'/api/system/storage/settings',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ logRetainDays: 0 }),
headers: new Headers({
'X-SnowLuma-Token': 'test-token',
'Content-Type': 'application/json',
}),
signal: expect.any(AbortSignal),
}),
);
});
it('returns a structured partial cleanup result from an HTTP 500 response', async () => {
const payload = {
success: false,
message: '部分文件清理失败,请检查失败明细',
scope: 'temporary',
cleanup: {
deletedFiles: 1,
freedBytes: 5,
skippedActiveItems: 0,
failures: [{ item: 'locked.bin', message: 'permission denied' }],
},
snapshot: {},
lastCleanup: {
at: '2026-07-24T04:00:00.000Z',
scope: 'temporary',
accountScope: 'global',
deletedFiles: 1,
freedBytes: 5,
failureCount: 1,
skippedActiveItems: 0,
failures: [{ item: 'locked.bin', message: 'permission denied' }],
},
};
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify(payload), {
status: 500,
headers: { 'content-type': 'application/json' },
}),
),
);
const client = createApiClient({ tokenStore });
await expect(client.storage.cleanup({ scope: 'temporary' })).resolves.toEqual(payload);
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
buildLogSettingsPatch,
formatBytes,
isAllAccountsConfirmation,
logStoragePresentation,
} from '../src/lib/storage-presentation';
describe('storage presentation helpers', () => {
it('omits environment-locked and unchanged fields from a settings patch', () => {
expect(
buildLogSettingsPatch(
{ logMaxTotalMb: 2048, logRetainDays: 0, logPerUin: true },
{ logMaxTotalMb: 1024, logRetainDays: 7, logPerUin: false },
['logMaxTotalMb'],
),
).toEqual({
logRetainDays: 0,
logPerUin: true,
});
});
it('prioritizes degraded state over capacity percentage and warns at 80 percent', () => {
expect(
logStoragePresentation({
state: 'degraded',
totalBytes: 10,
maxTotalBytes: 100,
}),
).toMatchObject({ label: '磁盘写入已暂停', tone: 'danger', percent: 10 });
expect(
logStoragePresentation({
state: 'healthy',
totalBytes: 80,
maxTotalBytes: 100,
}),
).toMatchObject({ label: '接近上限', tone: 'warning', percent: 80 });
expect(
logStoragePresentation({
state: 'healthy',
totalBytes: 20,
maxTotalBytes: 100,
}),
).toMatchObject({ label: '正常', tone: 'success', percent: 20 });
});
it('formats byte counts without exposing implementation units', () => {
expect(formatBytes(0)).toBe('0 B');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(1024 * 1024)).toBe('1 MB');
});
it('requires the exact all-account confirmation phrase', () => {
expect(isAllAccountsConfirmation('清理全部账号')).toBe(true);
expect(isAllAccountsConfirmation(' 清理全部账号 ')).toBe(false);
expect(isAllAccountsConfirmation('确认')).toBe(false);
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';
import { defaultWebuiPresentation, WEBUI_PRESENTATION_EVENT } from '@snowluma/common/webui-target';
import { createPresentationStore } from '../src/appearance/store';
import { subscribeHostAppearance } from '../src/appearance/runtime';
import { resolveAppearance } from '../src/appearance/registry';
import { maibotAppearance } from '../src/appearance/targets/maibot';
import { DEFAULT_APPEARANCE } from '../src/contexts/ThemeContext';
const initial = defaultWebuiPresentation('maibot');
describe('reversible presentation overlay', () => {
it('retains saved appearance, wallpaper, custom CSS and accessibility preferences', () => {
const saved = structuredClone(DEFAULT_APPEARANCE);
Object.assign(saved, {
customCss: 'body { color: red; }',
palette: 'catppuccin-mocha',
radius: 1,
fontSansCustom: 'My Font',
customPointerSystem: true,
reduceMotion: true,
disableMotion: true,
highContrast: true,
uiScale: 1.2,
});
saved.background.type = 'image';
saved.background.hasImage = true;
const before = structuredClone(saved);
const effective = resolveAppearance(saved, { ...initial, mode: 'light' }, maibotAppearance);
expect(effective).toMatchObject({
mode: 'light',
palette: 'default',
radius: 0,
customCss: '',
background: { type: 'none' },
customPointerSystem: false,
reduceMotion: true,
disableMotion: true,
highContrast: true,
uiScale: 1.2,
});
expect(saved).toEqual(before);
expect(resolveAppearance(saved, null, maibotAppearance)).toBe(saved);
expect(resolveAppearance(saved, null, null)).toBe(saved);
});
it('cannot enable a target through host messages on a standalone deployment', () => {
const store = createPresentationStore(null);
const host = new EventTarget() as Window;
const add = vi.spyOn(host, 'addEventListener');
const cleanup = subscribeHostAppearance(host, store);
store.receive(initial);
expect(store.getSnapshot()).toBeNull();
expect(add).not.toHaveBeenCalled();
cleanup();
});
it('applies the latest of rapid updates synchronously, deduplicates, releases and restores', () => {
const store = createPresentationStore('maibot');
const notify = vi.fn();
const cleanup = store.subscribe(notify);
store.receive(initial);
expect(notify).not.toHaveBeenCalled();
store.receive({ ...initial, mode: 'dark' });
store.receive({ ...initial, mode: 'light' });
expect(store.getSnapshot()?.mode).toBe('light');
store.receive({ version: 1, target: null });
expect(store.getSnapshot()).toBeNull();
store.receive(initial);
expect(store.getSnapshot()).toEqual(initial);
cleanup();
store.receive({ ...initial, mode: 'dark' });
expect(notify).toHaveBeenCalledTimes(4);
});
it('reads pre-mount host state, reports invalid input and cleans up subscriptions', () => {
const store = createPresentationStore('maibot');
const host = new EventTarget() as Window;
host.__SNOWLUMA_HOST_APPEARANCE__ = { ...initial, mode: 'dark' };
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const cleanup = subscribeHostAppearance(host, store);
try {
expect(store.getSnapshot()?.mode).toBe('dark');
host.dispatchEvent(
new CustomEvent(WEBUI_PRESENTATION_EVENT, {
detail: { ...initial, mode: 'invalid' },
}),
);
expect(error).toHaveBeenCalledOnce();
expect(store.getSnapshot()?.mode).toBe('dark');
host.dispatchEvent(
new CustomEvent(WEBUI_PRESENTATION_EVENT, {
detail: { ...initial, mode: 'light' },
}),
);
expect(store.getSnapshot()?.mode).toBe('light');
cleanup();
host.dispatchEvent(new CustomEvent(WEBUI_PRESENTATION_EVENT, { detail: initial }));
expect(store.getSnapshot()?.mode).toBe('light');
} finally {
cleanup();
error.mockRestore();
}
});
});
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { parseSecondFactor } from '../src/lib/totp-second-factor';
describe('parseSecondFactor', () => {
it('treats a 6-digit code as TOTP, ignoring spaces', () => {
expect(parseSecondFactor('287 082')).toEqual({ totp: '287082' });
});
it('treats a recovery code as a recovery code', () => {
expect(parseSecondFactor('AB3D-K7MQ')).toEqual({ recoveryCode: 'AB3D-K7MQ' });
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
allowEmptyInboundAccessToken,
isLoopbackHostname,
shouldWarnAboutInsecureRemoteAccess,
} from '../src/lib/transport-security';
describe('WebUI transport security notice', () => {
it.each([
'localhost',
'dashboard.localhost',
'127.0.0.1',
'127.42.1.9',
'[::1]',
'0:0:0:0:0:0:0:1',
'::ffff:127.0.0.1',
])('recognizes %s as a loopback browser hostname', (hostname) => {
expect(isLoopbackHostname(hostname)).toBe(true);
});
it.each(['0.0.0.0', '192.168.1.20', '10.0.0.8', 'snowluma.example.com', '::ffff:192.168.1.20'])(
'does not treat %s as loopback',
(hostname) => {
expect(isLoopbackHostname(hostname)).toBe(false);
},
);
it('allows an empty inbound token when the bind host is loopback, even from a remote WebUI', () => {
expect(allowEmptyInboundAccessToken('192.168.1.20', '127.0.0.1')).toBe(true);
expect(allowEmptyInboundAccessToken('panel.example.com', 'localhost')).toBe(true);
expect(allowEmptyInboundAccessToken('192.168.1.20', '0.0.0.0')).toBe(false);
expect(allowEmptyInboundAccessToken('192.168.1.20', '')).toBe(false);
});
it('still allows an empty inbound token when the WebUI itself is opened on loopback', () => {
expect(allowEmptyInboundAccessToken('127.0.0.1', '0.0.0.0')).toBe(true);
expect(allowEmptyInboundAccessToken('localhost', undefined)).toBe(true);
});
it('warns only when a non-loopback page is accessed without HTTPS', () => {
expect(
shouldWarnAboutInsecureRemoteAccess({ protocol: 'http:', hostname: '192.168.1.20' }),
).toBe(true);
expect(
shouldWarnAboutInsecureRemoteAccess({ protocol: 'https:', hostname: '192.168.1.20' }),
).toBe(false);
expect(
shouldWarnAboutInsecureRemoteAccess({ protocol: 'http:', hostname: '127.0.0.1' }),
).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createApiClient } from '../src/lib/api/client';
afterEach(() => vi.unstubAllGlobals());
describe('update API', () => {
it('loads, tests and saves mirror selection through authenticated object endpoints', async () => {
const config = { mode: 'auto', mirror: '', mirrors: ['https://example.com'] } as const;
const payload = { ...config, mirrors: [...config.mirrors] };
const fetchMock = vi.fn<typeof fetch>(async () =>
Response.json({ config: payload, results: [] }),
);
vi.stubGlobal('fetch', fetchMock);
const api = createApiClient({ tokenStore: { load: () => 'token', save: () => undefined } });
await api.update.sources();
await api.update.testSources(payload);
await api.update.saveSources(payload);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/update/sources',
'/api/update/sources/test',
'/api/update/sources',
]);
for (const [, options] of fetchMock.mock.calls) {
expect(new Headers(options?.headers).get('X-SnowLuma-Token')).toBe('token');
}
expect(fetchMock.mock.calls[1]?.[1]?.body).toBe(JSON.stringify(payload));
expect(fetchMock.mock.calls[2]?.[1]?.method).toBe('POST');
});
it('sends authenticated download and restart requests', async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
Response.json({ current: '1.0.0', phase: 'ready', version: '2.0.0' }),
);
vi.stubGlobal('fetch', fetchMock);
const api = createApiClient({ tokenStore: { load: () => 'token', save: () => undefined } });
await api.update.download('2.0.0');
await api.update.restart();
await api.update.status();
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/update/download',
'/api/update/restart',
'/api/update/status',
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/update/download',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ version: '2.0.0' }),
headers: new Headers({
'Content-Type': 'application/json',
'X-SnowLuma-Token': 'token',
}),
}),
);
});
it('surfaces a rejected restart instead of treating it as complete', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ message: '更新包尚未就绪' }, { status: 409 })),
);
const api = createApiClient({ tokenStore: { load: () => 'token', save: () => undefined } });
await expect(api.update.restart()).rejects.toThrow('更新包尚未就绪');
});
});