test: 收紧消息推送前端契约

This commit is contained in:
sunlei 2026-07-24 15:18:34 +08:00
parent edb77bd67c
commit c072d53554
3 changed files with 905 additions and 52 deletions

View File

@ -1,7 +1,70 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
import ts from 'typescript';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const requestClientGet = vi.fn();
const messagePushMenuNames = [
'QqBotMessageSubscription',
'QqBotMessageTemplate',
'QqBotMessageSubscriptionList',
'QqBotMessageSubscriptionCreate',
'QqBotMessageSubscriptionUpdate',
'QqBotMessageSubscriptionDelete',
'QqBotMessageSubscriptionToggle',
'QqBotMessageTemplateList',
'QqBotMessageTemplateCreate',
'QqBotMessageTemplateUpdate',
'QqBotMessageTemplateDelete',
'QqBotMessageTemplateToggle',
'QqBotMessageTemplatePreview',
'QqBotAccountMessagePushList',
'QqBotAccountMessagePushCreate',
'QqBotAccountMessagePushUpdate',
'QqBotAccountMessagePushDelete',
'QqBotAccountMessagePushToggle',
];
/**
* Reads the private menu whitelist's literal source array without exporting it.
* @returns Every string literal used to initialize `SUPPORTED_ADMIN_MENU_NAMES`.
*/
function getSupportedAdminMenuNameLiterals() {
const sourceFile = ts.createSourceFile(
'menu.ts',
readFileSync(resolve('apps/web-antdv-next/src/api/core/menu.ts'), 'utf8'),
ts.ScriptTarget.Latest,
true,
);
const declaration = sourceFile.statements
.filter((statement) => ts.isVariableStatement(statement))
.flatMap((statement) => statement.declarationList.declarations)
.find(
(candidate) =>
ts.isIdentifier(candidate.name) &&
candidate.name.text === 'SUPPORTED_ADMIN_MENU_NAMES',
);
expect(declaration?.initializer).toSatisfy(ts.isNewExpression);
const initializer = declaration?.initializer as ts.NewExpression;
expect(initializer.expression).toSatisfy(
(expression: ts.Expression) =>
ts.isIdentifier(expression) && expression.text === 'Set',
);
expect(initializer.arguments).toHaveLength(1);
expect(initializer.arguments?.[0]).toSatisfy(ts.isArrayLiteralExpression);
const elements = (initializer.arguments?.[0] as ts.ArrayLiteralExpression)
.elements;
expect(elements.every((element) => ts.isStringLiteral(element))).toBe(true);
return elements.map((element) => (element as ts.StringLiteral).text);
}
vi.mock('#/api/request', () => ({
requestClient: {
get: requestClientGet,
@ -393,26 +456,6 @@ describe('core menu api', () => {
});
it('keeps every supported message-push menu name once while filtering unknown nodes', async () => {
const messagePushNames = [
'QqBotMessageSubscription',
'QqBotMessageTemplate',
'QqBotMessageSubscriptionList',
'QqBotMessageSubscriptionCreate',
'QqBotMessageSubscriptionUpdate',
'QqBotMessageSubscriptionDelete',
'QqBotMessageSubscriptionToggle',
'QqBotMessageTemplateList',
'QqBotMessageTemplateCreate',
'QqBotMessageTemplateUpdate',
'QqBotMessageTemplateDelete',
'QqBotMessageTemplateToggle',
'QqBotMessageTemplatePreview',
'QqBotAccountMessagePushList',
'QqBotAccountMessagePushCreate',
'QqBotAccountMessagePushUpdate',
'QqBotAccountMessagePushDelete',
'QqBotAccountMessagePushToggle',
];
requestClientGet.mockResolvedValue([
{
name: 'QqBot',
@ -422,7 +465,7 @@ describe('core menu api', () => {
name: 'QqBotAccount',
path: '/qqbot/account',
},
...messagePushNames.map((name) => ({
...messagePushMenuNames.map((name) => ({
authCode: `QqBot:${name}`,
name,
type: 'button',
@ -440,8 +483,18 @@ describe('core menu api', () => {
const qqbot = menus.find((menu) => menu.name === 'QqBot');
const retainedNames = qqbot?.children?.map((child) => child.name) ?? [];
expect(retainedNames).toEqual(['QqBotAccount', ...messagePushNames]);
expect(retainedNames).toEqual(['QqBotAccount', ...messagePushMenuNames]);
expect(new Set(retainedNames).size).toBe(retainedNames.length);
expect(retainedNames).not.toContain('UnsupportedMessagePushNode');
});
it('declares every locked message-push menu literal exactly once', () => {
const literals = getSupportedAdminMenuNameLiterals();
expect(new Set(literals).size).toBe(literals.length);
for (const name of messagePushMenuNames) {
expect(literals.filter((literal) => literal === name)).toHaveLength(1);
}
});
});

View File

@ -1,3 +1,9 @@
import type { QqbotMessagePushApi } from './message-push';
import { resolve } from 'node:path';
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
import ts from 'typescript';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestClient } from '#/api/request';
@ -26,6 +32,616 @@ import {
updateMessageTemplate,
} from './message-push';
type Equal<Actual, Expected> =
(<Value>() => Value extends Actual ? 1 : 2) extends <
Value,
>() => Value extends Expected ? 1 : 2
? true
: false;
type Assert<Condition extends true> = Condition;
type ExpectedSystemMessageSourceVariableDefinition = {
description: string;
example: string;
key: string;
label: string;
type: 'boolean' | 'number' | 'string';
};
type ExpectedSystemMessageSourceFieldDefinition = {
dependsOn?: string;
key: string;
label: string;
optionCollection: 'ddnsRecords' | 'portForwards';
required: true;
type: 'select';
};
type ExpectedSystemMessageSourceDefinition = {
description: string;
displayName: string;
sourceKey: string;
subscriptionFields: ExpectedSystemMessageSourceFieldDefinition[];
variables: ExpectedSystemMessageSourceVariableDefinition[];
version: 1;
};
type ExpectedStunMappingPortChangedOptionsResponse = {
ddnsRecords: Array<{
disabledReasonCode: null | string;
eligible: boolean;
fqdn: string;
id: string;
name: string;
portForwardId: string;
}>;
portForwards: Array<{
disabledReasonCode: null | string;
eligible: boolean;
externalPort: number;
id: string;
internalPort: number;
name: string;
protocol: 'tcp' | 'udp';
}>;
};
type ExpectedMessageSubscriptionView = {
createTime: string;
enabled: boolean;
id: string;
invalidReasonCode: null | string;
name: string;
remark: null | string;
sourceConfig: { ddnsRecordId: string; portForwardId: string };
sourceKey: string;
sourceName: string;
sourceSummary: string;
updateTime: string;
valid: boolean;
};
type ExpectedMessageSubscriptionListQuery = {
enabled?: boolean;
name?: string;
pageNo?: number;
pageSize?: number;
sourceKey?: string;
};
type ExpectedMessageSubscriptionInput = {
enabled: boolean;
name: string;
remark?: string;
sourceConfig: Record<string, unknown>;
sourceKey: string;
};
type ExpectedMessageTemplateView = {
content: string;
createTime: string;
enabled: boolean;
id: string;
name: string;
referenceCount: number;
remark: null | string;
sourceKey: string;
sourceName: string;
updateTime: string;
};
type ExpectedMessageTemplateListQuery = ExpectedMessageSubscriptionListQuery;
type ExpectedMessageTemplateInput = {
content: string;
enabled: boolean;
name: string;
remark?: string;
sourceKey: string;
};
type ExpectedMessageTemplatePreview = {
renderedMessage: string;
variables: Record<string, boolean | number | string>;
};
type ExpectedQqbotMessagePublishBindingInput = {
enabled: boolean;
subscriptionId: string;
targets: Array<{
targetId: string;
targetName?: string;
targetType: 'group' | 'private';
}>;
templateId: string;
};
type ExpectedQqbotMessagePushTargetOptionsResponse = {
available: boolean;
options: Array<{
label: string;
targetId: string;
targetType: 'group' | 'private';
}>;
reasonCode: null | string;
};
type ExpectedQqbotMessagePublishBindingView = {
available: boolean;
createTime: string;
enabled: boolean;
id: string;
invalidReasonCode: null | string;
sourceKey: string;
sourceName: string;
subscriptionId: string;
subscriptionName: string;
targets: Array<{
enabled: boolean;
id: string;
targetId: string;
targetName: null | string;
targetType: 'group' | 'private';
}>;
templateId: string;
templateName: string;
updateTime: string;
};
type MessagePushTypeContracts = [
Assert<
Equal<
QqbotMessagePushApi.SystemMessageScalar,
boolean | null | number | string
>
>,
Assert<
Equal<
QqbotMessagePushApi.SystemMessageSourceVariableDefinition,
ExpectedSystemMessageSourceVariableDefinition
>
>,
Assert<
Equal<
QqbotMessagePushApi.SystemMessageSourceFieldDefinition,
ExpectedSystemMessageSourceFieldDefinition
>
>,
Assert<
Equal<
QqbotMessagePushApi.SystemMessageSourceDefinition,
ExpectedSystemMessageSourceDefinition
>
>,
Assert<
Equal<
QqbotMessagePushApi.StunMappingPortChangedSubscriptionConfig,
{ ddnsRecordId: string; portForwardId: string }
>
>,
Assert<
Equal<
QqbotMessagePushApi.StunMappingPortChangedOptionsResponse,
ExpectedStunMappingPortChangedOptionsResponse
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageSubscriptionView,
ExpectedMessageSubscriptionView
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageSubscriptionListQuery,
ExpectedMessageSubscriptionListQuery
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageSubscriptionInput,
ExpectedMessageSubscriptionInput
>
>,
Assert<
Equal<QqbotMessagePushApi.MessageTemplateView, ExpectedMessageTemplateView>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageTemplateListQuery,
ExpectedMessageTemplateListQuery
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageTemplateInput,
ExpectedMessageTemplateInput
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageTemplatePreviewInput,
{ content: string; sourceKey: string }
>
>,
Assert<
Equal<
QqbotMessagePushApi.MessageTemplatePreview,
ExpectedMessageTemplatePreview
>
>,
Assert<
Equal<QqbotMessagePushApi.QqbotMessagePushTargetType, 'group' | 'private'>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePublishTargetInput,
{ targetId: string; targetName?: string; targetType: 'group' | 'private' }
>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePublishBindingInput,
ExpectedQqbotMessagePublishBindingInput
>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePushTargetOption,
{ label: string; targetId: string; targetType: 'group' | 'private' }
>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePushTargetOptionsResponse,
ExpectedQqbotMessagePushTargetOptionsResponse
>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePublishTargetView,
{
enabled: boolean;
id: string;
targetId: string;
targetName: null | string;
targetType: 'group' | 'private';
}
>
>,
Assert<
Equal<
QqbotMessagePushApi.QqbotMessagePublishBindingView,
ExpectedQqbotMessagePublishBindingView
>
>,
Assert<
Equal<
QqbotMessagePushApi.PageResult<ExpectedMessageSubscriptionView>,
{ items: ExpectedMessageSubscriptionView[]; total: number }
>
>,
Assert<Equal<Parameters<typeof getMessagePushSources>, []>>,
Assert<
Equal<
ReturnType<typeof getMessagePushSources>,
Promise<ExpectedSystemMessageSourceDefinition[]>
>
>,
Assert<
Equal<Parameters<typeof getMessagePushSourceDetail>, [sourceKey: string]>
>,
Assert<
Equal<
ReturnType<typeof getMessagePushSourceDetail>,
Promise<ExpectedSystemMessageSourceDefinition>
>
>,
Assert<Equal<Parameters<typeof getStunMappingPortChangedOptions>, []>>,
Assert<
Equal<
ReturnType<typeof getStunMappingPortChangedOptions>,
Promise<ExpectedStunMappingPortChangedOptionsResponse>
>
>,
Assert<
Equal<
Parameters<typeof getMessageSubscriptionList>,
[params: ExpectedMessageSubscriptionListQuery]
>
>,
Assert<
Equal<
ReturnType<typeof getMessageSubscriptionList>,
Promise<{ items: ExpectedMessageSubscriptionView[]; total: number }>
>
>,
Assert<
Equal<
Parameters<typeof createMessageSubscription>,
[data: ExpectedMessageSubscriptionInput]
>
>,
Assert<
Equal<
ReturnType<typeof createMessageSubscription>,
Promise<ExpectedMessageSubscriptionView>
>
>,
Assert<
Equal<
Parameters<typeof updateMessageSubscription>,
[id: string, data: ExpectedMessageSubscriptionInput]
>
>,
Assert<
Equal<
ReturnType<typeof updateMessageSubscription>,
Promise<ExpectedMessageSubscriptionView>
>
>,
Assert<
Equal<
Parameters<typeof setMessageSubscriptionEnabled>,
[id: string, enabled: boolean]
>
>,
Assert<
Equal<
ReturnType<typeof setMessageSubscriptionEnabled>,
Promise<ExpectedMessageSubscriptionView>
>
>,
Assert<Equal<Parameters<typeof deleteMessageSubscription>, [id: string]>>,
Assert<Equal<ReturnType<typeof deleteMessageSubscription>, Promise<boolean>>>,
Assert<
Equal<
Parameters<typeof getMessageTemplateList>,
[params: ExpectedMessageTemplateListQuery]
>
>,
Assert<
Equal<
ReturnType<typeof getMessageTemplateList>,
Promise<{ items: ExpectedMessageTemplateView[]; total: number }>
>
>,
Assert<
Equal<
Parameters<typeof createMessageTemplate>,
[data: ExpectedMessageTemplateInput]
>
>,
Assert<
Equal<
ReturnType<typeof createMessageTemplate>,
Promise<ExpectedMessageTemplateView>
>
>,
Assert<
Equal<
Parameters<typeof updateMessageTemplate>,
[id: string, data: ExpectedMessageTemplateInput]
>
>,
Assert<
Equal<
ReturnType<typeof updateMessageTemplate>,
Promise<ExpectedMessageTemplateView>
>
>,
Assert<
Equal<
Parameters<typeof setMessageTemplateEnabled>,
[id: string, enabled: boolean]
>
>,
Assert<
Equal<
ReturnType<typeof setMessageTemplateEnabled>,
Promise<ExpectedMessageTemplateView>
>
>,
Assert<Equal<Parameters<typeof deleteMessageTemplate>, [id: string]>>,
Assert<Equal<ReturnType<typeof deleteMessageTemplate>, Promise<boolean>>>,
Assert<
Equal<
Parameters<typeof previewMessageTemplate>,
[data: { content: string; sourceKey: string }]
>
>,
Assert<
Equal<
ReturnType<typeof previewMessageTemplate>,
Promise<ExpectedMessageTemplatePreview>
>
>,
Assert<
Equal<Parameters<typeof getAccountMessagePushBindings>, [selfId: string]>
>,
Assert<
Equal<
ReturnType<typeof getAccountMessagePushBindings>,
Promise<ExpectedQqbotMessagePublishBindingView[]>
>
>,
Assert<
Equal<
Parameters<typeof createAccountMessagePushBinding>,
[selfId: string, data: ExpectedQqbotMessagePublishBindingInput]
>
>,
Assert<
Equal<
ReturnType<typeof createAccountMessagePushBinding>,
Promise<ExpectedQqbotMessagePublishBindingView>
>
>,
Assert<
Equal<
Parameters<typeof updateAccountMessagePushBinding>,
[
selfId: string,
id: string,
data: ExpectedQqbotMessagePublishBindingInput,
]
>
>,
Assert<
Equal<
ReturnType<typeof updateAccountMessagePushBinding>,
Promise<ExpectedQqbotMessagePublishBindingView>
>
>,
Assert<
Equal<
Parameters<typeof setAccountMessagePushBindingEnabled>,
[selfId: string, id: string, enabled: boolean]
>
>,
Assert<
Equal<
ReturnType<typeof setAccountMessagePushBindingEnabled>,
Promise<ExpectedQqbotMessagePublishBindingView>
>
>,
Assert<
Equal<
Parameters<typeof deleteAccountMessagePushBinding>,
[selfId: string, id: string]
>
>,
Assert<
Equal<ReturnType<typeof deleteAccountMessagePushBinding>, Promise<boolean>>
>,
Assert<
Equal<Parameters<typeof getAccountMessagePushTargets>, [selfId: string]>
>,
Assert<
Equal<
ReturnType<typeof getAccountMessagePushTargets>,
Promise<ExpectedQqbotMessagePushTargetOptionsResponse>
>
>,
];
const messagePushCallerNames = [
'createAccountMessagePushBinding',
'createMessageSubscription',
'createMessageTemplate',
'deleteAccountMessagePushBinding',
'deleteMessageSubscription',
'deleteMessageTemplate',
'getAccountMessagePushBindings',
'getAccountMessagePushTargets',
'getMessagePushSourceDetail',
'getMessagePushSources',
'getMessageSubscriptionList',
'getMessageTemplateList',
'getStunMappingPortChangedOptions',
'previewMessageTemplate',
'setAccountMessagePushBindingEnabled',
'setMessageSubscriptionEnabled',
'setMessageTemplateEnabled',
'updateAccountMessagePushBinding',
'updateMessageSubscription',
'updateMessageTemplate',
];
/**
* Type-checks this focused contract spec with a virtual request client boundary.
* @returns Deterministic TypeScript diagnostics for only this spec and `message-push.ts`.
*/
function getMessagePushContractDiagnostics() {
const appRoot = resolve('apps/web-antdv-next');
const configPath = resolve(appRoot, 'tsconfig.json');
const testFilePath = resolve(appRoot, 'src/api/qqbot/message-push.spec.ts');
const virtualRequestModulePath =
'/__qqbot-message-push-contract__/request.ts';
const virtualRequestModuleSource = `
export declare const requestClient: {
delete<T>(url: string, config?: unknown): Promise<T>;
get<T>(url: string, config?: unknown): Promise<T>;
post<T>(url: string, data?: unknown, config?: unknown): Promise<T>;
put<T>(url: string, data?: unknown, config?: unknown): Promise<T>;
};`;
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
appRoot,
{ incremental: false, noEmit: true },
configPath,
);
const baseCompilerHost = ts.createCompilerHost(parsed.options, true);
const compilerHost: ts.CompilerHost = {
...baseCompilerHost,
fileExists(fileName) {
return (
fileName === virtualRequestModulePath ||
baseCompilerHost.fileExists(fileName)
);
},
getSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
) {
if (fileName === virtualRequestModulePath) {
return ts.createSourceFile(
fileName,
virtualRequestModuleSource,
languageVersion,
true,
);
}
return baseCompilerHost.getSourceFile(
fileName,
languageVersion,
onError,
shouldCreateNewSourceFile,
);
},
readFile(fileName) {
return fileName === virtualRequestModulePath
? virtualRequestModuleSource
: baseCompilerHost.readFile(fileName);
},
resolveModuleNames(moduleNames, containingFile) {
return moduleNames.map((moduleName) => {
if (moduleName === '#/api/request') {
return {
extension: ts.Extension.Ts,
isExternalLibraryImport: false,
resolvedFileName: virtualRequestModulePath,
};
}
return ts.resolveModuleName(
moduleName,
containingFile,
parsed.options,
baseCompilerHost,
).resolvedModule;
});
},
};
const program = ts.createProgram({
host: compilerHost,
options: parsed.options,
rootNames: [testFilePath],
});
return ts
.getPreEmitDiagnostics(program)
.map((diagnostic) =>
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
);
}
vi.mock('#/api/request', () => ({
requestClient: {
delete: vi.fn(),
@ -214,11 +830,16 @@ describe('qqbot message push api', () => {
);
});
it('does not expose internal publish, event, delivery, or worker callers', () => {
expect(Object.keys(messagePushApi)).not.toEqual(
expect.arrayContaining([
expect.stringMatching(/delivery|event|publish|worker/i),
]),
it('exports exactly the twenty approved callers', () => {
expect(Object.keys(messagePushApi).toSorted()).toEqual(
messagePushCallerNames,
);
});
it('type-checks the complete public namespace and caller contract', () => {
const typeContracts: MessagePushTypeContracts = [] as never;
void typeContracts;
expect(getMessagePushContractDiagnostics()).toEqual([]);
});
});

View File

@ -1,33 +1,212 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// eslint-disable-next-line n/no-extraneous-import -- TypeScript is the workspace compiler used for source-level contract checks.
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
describe('qqbot routes', () => {
it('adds two flat message-push route children without loading future pages', () => {
const source = readFileSync(
'apps/web-antdv-next/src/router/routes/modules/qqbot.ts',
'utf8',
);
type ExpectedMessagePushRoute = {
componentPath: string;
name: string;
path: string;
title: string;
};
for (const [name, path, componentPath, title] of [
[
'QqBotMessageSubscription',
'/qqbot/message-subscription',
'#/views/qqbot/message-subscription/list',
'消息订阅',
],
[
'QqBotMessageTemplate',
'/qqbot/message-template',
'#/views/qqbot/message-template/list',
'消息模板',
],
] as const) {
expect(source).toMatch(
new RegExp(
String.raw`\{\s*component: \(\) => import\('${componentPath}'\),\s*meta: \{\s*icon: 'lucide:[^']+',\s*title: '${title}',\s*\},\s*name: '${name}',\s*path: '${path}',\s*\}`,
's',
),
const messagePushRoutes: ExpectedMessagePushRoute[] = [
{
componentPath: '#/views/qqbot/message-subscription/list',
name: 'QqBotMessageSubscription',
path: '/qqbot/message-subscription',
title: '消息订阅',
},
{
componentPath: '#/views/qqbot/message-template/list',
name: 'QqBotMessageTemplate',
path: '/qqbot/message-template',
title: '消息模板',
},
];
/**
* Returns the text key of a non-computed object-literal property.
* @param property - Candidate route object property from the parsed source.
* @returns The literal property key, or `undefined` when it cannot be read statically.
*/
function getPropertyName(property: ts.ObjectLiteralElementLike) {
if (!ts.isPropertyAssignment(property) || property.name === undefined) {
return undefined;
}
return ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)
? property.name.text
: undefined;
}
/**
* Finds one directly declared property on a route object.
* @param object - Route object literal to inspect.
* @param name - Required literal property key.
* @returns The matching property assignment, if present.
*/
function getProperty(object: ts.ObjectLiteralExpression, name: string) {
return object.properties.find(
(property): property is ts.PropertyAssignment =>
getPropertyName(property) === name && ts.isPropertyAssignment(property),
);
}
/**
* Reads a required string-literal property from a route object.
* @param object - Route object literal to inspect.
* @param name - Required literal property key.
* @returns The declared string value, or `undefined` when it is not a string literal.
*/
function getStringProperty(object: ts.ObjectLiteralExpression, name: string) {
const property = getProperty(object, name);
return property && ts.isStringLiteral(property.initializer)
? property.initializer.text
: undefined;
}
/**
* Reads the literal target from a route's lazy dynamic import without invoking it.
* @param object - Route object literal to inspect.
* @returns The module path passed to `import()`.
*/
function getLazyComponentPath(object: ts.ObjectLiteralExpression) {
const component = getProperty(object, 'component');
expect(component, 'route must declare a lazy component').toBeDefined();
expect(component?.initializer).toSatisfy(ts.isArrowFunction);
const body = (component?.initializer as ts.ArrowFunction).body;
expect(body).toSatisfy(ts.isCallExpression);
const importCall = body as ts.CallExpression;
expect(importCall.expression.kind).toBe(ts.SyntaxKind.ImportKeyword);
expect(importCall.arguments).toHaveLength(1);
expect(importCall.arguments[0]).toSatisfy(ts.isStringLiteral);
return (importCall.arguments[0] as ts.StringLiteral).text;
}
/**
* Locates the direct `QqBot.children` array in the route initializer.
* @param sourceFile - Parsed QQBot route module source.
* @returns The direct child route objects of the `QqBot` parent.
*/
function getQqBotChildren(sourceFile: ts.SourceFile) {
const routesDeclaration = sourceFile.statements.find(
(statement): statement is ts.VariableStatement => {
if (!ts.isVariableStatement(statement)) {
return false;
}
return statement.declarationList.declarations.some(
(declaration) =>
ts.isIdentifier(declaration.name) &&
declaration.name.text === 'routes',
);
},
);
const routesInitializer =
routesDeclaration?.declarationList.declarations.find(
(declaration) =>
ts.isIdentifier(declaration.name) && declaration.name.text === 'routes',
)?.initializer;
expect(routesInitializer).toSatisfy(ts.isArrayLiteralExpression);
const qqbotRoute = (
routesInitializer as ts.ArrayLiteralExpression
).elements.find(
(element): element is ts.ObjectLiteralExpression =>
ts.isObjectLiteralExpression(element) &&
getStringProperty(element, 'name') === 'QqBot',
);
expect(qqbotRoute, 'routes must contain a direct QqBot parent').toBeDefined();
const children = getProperty(
qqbotRoute as ts.ObjectLiteralExpression,
'children',
);
expect(children?.initializer).toSatisfy(ts.isArrayLiteralExpression);
return (children?.initializer as ts.ArrayLiteralExpression).elements.filter(
(element) => ts.isObjectLiteralExpression(element),
);
}
/**
* Collects every route object that declares one of the locked message-push names.
* @param sourceFile - Parsed QQBot route module source.
* @returns All matching objects, including accidental nested or duplicate routes.
*/
function getAllMessagePushRoutes(sourceFile: ts.SourceFile) {
const routeNames = new Set(messagePushRoutes.map((route) => route.name));
const matches: ts.ObjectLiteralExpression[] = [];
/** Visits route objects so nested copies cannot evade the direct-child assertion. */
function visit(node: ts.Node): void {
if (
ts.isObjectLiteralExpression(node) &&
routeNames.has(getStringProperty(node, 'name'))
) {
matches.push(node);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return matches;
}
describe('qqbot routes', () => {
it('adds exactly two flat message-push route children without loading future pages', () => {
const sourceFile = ts.createSourceFile(
'qqbot.ts',
readFileSync(
resolve('apps/web-antdv-next/src/router/routes/modules/qqbot.ts'),
'utf8',
),
ts.ScriptTarget.Latest,
true,
);
const directChildren = getQqBotChildren(sourceFile);
const allMessagePushRouteNodes = getAllMessagePushRoutes(sourceFile);
expect(allMessagePushRouteNodes).toHaveLength(messagePushRoutes.length);
for (const expectedRoute of messagePushRoutes) {
const matchingDirectChildren = directChildren.filter(
(route) => getStringProperty(route, 'name') === expectedRoute.name,
);
expect(matchingDirectChildren).toHaveLength(1);
const route = matchingDirectChildren[0];
if (!route) {
return;
}
expect(getStringProperty(route, 'path')).toBe(expectedRoute.path);
expect(getLazyComponentPath(route)).toBe(expectedRoute.componentPath);
expect(getProperty(route, 'children')).toBeUndefined();
const meta = getProperty(route, 'meta');
expect(meta?.initializer).toSatisfy(ts.isObjectLiteralExpression);
if (!meta || !ts.isObjectLiteralExpression(meta.initializer)) {
return;
}
expect(getStringProperty(meta.initializer, 'title')).toBe(
expectedRoute.title,
);
expect(getStringProperty(meta.initializer, 'icon')).toMatch(
/^lucide:\S+$/,
);
}
});