mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
* feat(session): auto-title sessions via fast model, add /rename --auto The /rename work in #3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks. * docs(session): design doc for auto session titles Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed. * test(rename): pin model choice in bare /rename kebab path Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Code
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { renameCommand } from './renameCommand.js';
|
|
import { type CommandContext } from './types.js';
|
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
|
|
|
const tryGenerateSessionTitleMock = vi.fn();
|
|
|
|
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|
const original =
|
|
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
|
|
return {
|
|
...original,
|
|
tryGenerateSessionTitle: (...args: unknown[]) =>
|
|
tryGenerateSessionTitleMock(...args),
|
|
};
|
|
});
|
|
|
|
describe('renameCommand', () => {
|
|
let mockContext: CommandContext;
|
|
|
|
beforeEach(() => {
|
|
mockContext = createMockCommandContext();
|
|
tryGenerateSessionTitleMock.mockReset();
|
|
});
|
|
|
|
it('should have the correct name and description', () => {
|
|
expect(renameCommand.name).toBe('rename');
|
|
expect(renameCommand.description).toBe(
|
|
'Rename the current conversation. --auto lets the fast model pick a title.',
|
|
);
|
|
});
|
|
|
|
it('should return error when config is not available', async () => {
|
|
mockContext.services.config = null;
|
|
|
|
const result = await renameCommand.action!(mockContext, 'my-feature');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Config is not available.',
|
|
});
|
|
});
|
|
|
|
it('should return error when no name is provided and auto-generate fails', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
getGeminiClient: vi.fn().mockReturnValue({
|
|
getHistory: vi.fn().mockReturnValue([]),
|
|
}),
|
|
getContentGenerator: vi.fn(),
|
|
getModel: vi.fn(),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Could not generate a title. Usage: /rename <name>',
|
|
});
|
|
});
|
|
|
|
it('should return error when only whitespace is provided and auto-generate fails', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
getGeminiClient: vi.fn().mockReturnValue({
|
|
getHistory: vi.fn().mockReturnValue([]),
|
|
}),
|
|
getContentGenerator: vi.fn(),
|
|
getModel: vi.fn(),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, ' ');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Could not generate a title. Usage: /rename <name>',
|
|
});
|
|
});
|
|
|
|
it('should rename via ChatRecordingService when available', async () => {
|
|
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: mockRecordCustomTitle,
|
|
}),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
};
|
|
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, 'my-feature');
|
|
|
|
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual');
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: 'Session renamed to "my-feature"',
|
|
});
|
|
});
|
|
|
|
it('should fall back to SessionService when ChatRecordingService is unavailable', async () => {
|
|
const mockRenameSession = vi.fn().mockResolvedValue(true);
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: mockRenameSession,
|
|
}),
|
|
};
|
|
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, 'my-feature');
|
|
|
|
expect(mockRenameSession).toHaveBeenCalledWith(
|
|
'test-session-id',
|
|
'my-feature',
|
|
'manual',
|
|
);
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: 'Session renamed to "my-feature"',
|
|
});
|
|
});
|
|
|
|
it('should return error when SessionService fallback fails', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: vi.fn().mockResolvedValue(false),
|
|
}),
|
|
};
|
|
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, 'my-feature');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Failed to rename session.',
|
|
});
|
|
});
|
|
|
|
describe('bare /rename model selection', () => {
|
|
// Pins the kebab-case path's model choice: bare `/rename` (no args)
|
|
// prefers fastModel when one is configured, falls back to the main
|
|
// model otherwise. Previous tests mocked `getHistory: []` which bailed
|
|
// before the model selection ran, leaving this regression-prone.
|
|
function mockConfigForKebab(opts: { fastModel?: string; model?: string }): {
|
|
config: unknown;
|
|
generateContent: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const generateContent = vi.fn().mockResolvedValue({
|
|
candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }],
|
|
});
|
|
const config = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn().mockReturnValue(true),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue(opts.fastModel),
|
|
getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'),
|
|
getGeminiClient: vi.fn().mockReturnValue({
|
|
getHistory: vi.fn().mockReturnValue([
|
|
{ role: 'user', parts: [{ text: 'fix the login bug' }] },
|
|
{
|
|
role: 'model',
|
|
parts: [{ text: 'Looking at the handler now.' }],
|
|
},
|
|
]),
|
|
}),
|
|
getContentGenerator: vi.fn().mockReturnValue({ generateContent }),
|
|
};
|
|
return { config, generateContent };
|
|
}
|
|
|
|
it('uses fastModel when configured', async () => {
|
|
const { config, generateContent } = mockConfigForKebab({
|
|
fastModel: 'qwen-turbo',
|
|
model: 'main-model',
|
|
});
|
|
mockContext = createMockCommandContext({
|
|
services: { config: config as never },
|
|
});
|
|
|
|
await renameCommand.action!(mockContext, '');
|
|
|
|
expect(generateContent).toHaveBeenCalledOnce();
|
|
expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo');
|
|
});
|
|
|
|
it('falls back to main model when fastModel is unset', async () => {
|
|
const { config, generateContent } = mockConfigForKebab({
|
|
fastModel: undefined,
|
|
model: 'main-model',
|
|
});
|
|
mockContext = createMockCommandContext({
|
|
services: { config: config as never },
|
|
});
|
|
|
|
await renameCommand.action!(mockContext, '');
|
|
|
|
expect(generateContent).toHaveBeenCalledOnce();
|
|
expect(generateContent.mock.calls[0][0].model).toBe('main-model');
|
|
});
|
|
});
|
|
|
|
describe('--auto flag', () => {
|
|
it('refuses --auto when no fast model is configured', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue(undefined),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content:
|
|
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
|
|
});
|
|
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses --auto combined with a positional name', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto my-name');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content:
|
|
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
|
|
});
|
|
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes an auto-sourced title on --auto success', async () => {
|
|
tryGenerateSessionTitleMock.mockResolvedValue({
|
|
ok: true,
|
|
title: 'Fix login button on mobile',
|
|
modelUsed: 'qwen-turbo',
|
|
});
|
|
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: mockRecordCustomTitle,
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
|
|
expect(mockRecordCustomTitle).toHaveBeenCalledWith(
|
|
'Fix login button on mobile',
|
|
'auto',
|
|
);
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: 'Session renamed to "Fix login button on mobile"',
|
|
});
|
|
});
|
|
|
|
it('surfaces empty_history reason with actionable hint', async () => {
|
|
tryGenerateSessionTitleMock.mockResolvedValue({
|
|
ok: false,
|
|
reason: 'empty_history',
|
|
});
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content:
|
|
'No conversation to title yet — send at least one message first.',
|
|
});
|
|
});
|
|
|
|
it('surfaces model_error reason distinctly', async () => {
|
|
tryGenerateSessionTitleMock.mockResolvedValue({
|
|
ok: false,
|
|
reason: 'model_error',
|
|
});
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(result).toMatchObject({
|
|
messageType: 'error',
|
|
});
|
|
expect((result as { content: string }).content).toMatch(
|
|
/rate limit, auth, or network error/,
|
|
);
|
|
});
|
|
|
|
it('rejects unknown flag with sentinel hint', async () => {
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(
|
|
mockContext,
|
|
'--my-label-with-dashes',
|
|
);
|
|
|
|
expect(result).toMatchObject({ messageType: 'error' });
|
|
const content = (result as { content: string }).content;
|
|
expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/);
|
|
expect(content).toMatch(/\/rename -- --my-label-with-dashes/);
|
|
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces aborted reason when user cancels', async () => {
|
|
tryGenerateSessionTitleMock.mockResolvedValue({
|
|
ok: false,
|
|
reason: 'aborted',
|
|
});
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue({
|
|
recordCustomTitle: vi.fn(),
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(result).toEqual({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
content: 'Title generation was cancelled.',
|
|
});
|
|
});
|
|
|
|
it('falls back to SessionService.renameSession with auto source', async () => {
|
|
tryGenerateSessionTitleMock.mockResolvedValue({
|
|
ok: true,
|
|
title: 'Audit auth middleware',
|
|
modelUsed: 'qwen-turbo',
|
|
});
|
|
const mockRenameSession = vi.fn().mockResolvedValue(true);
|
|
const mockConfig = {
|
|
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getSessionService: vi.fn().mockReturnValue({
|
|
renameSession: mockRenameSession,
|
|
}),
|
|
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
|
};
|
|
mockContext = createMockCommandContext({
|
|
services: { config: mockConfig as never },
|
|
});
|
|
|
|
const result = await renameCommand.action!(mockContext, '--auto');
|
|
|
|
expect(mockRenameSession).toHaveBeenCalledWith(
|
|
'test-session-id',
|
|
'Audit auth middleware',
|
|
'auto',
|
|
);
|
|
expect(result).toMatchObject({ messageType: 'info' });
|
|
});
|
|
});
|
|
});
|