SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('更新包尚未就绪');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user