Files
hertzbeat/web-app/scripts/dependency-rules.test.mjs
T

446 lines
22 KiB
JavaScript

/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { afterEach, test } from 'node:test';
const projectRoot = resolve(import.meta.dirname, '..');
const dependencyCruiser = join(projectRoot, 'node_modules', '.bin', 'depcruise');
const configuration = join(projectRoot, 'dependency-cruiser.config.cjs');
const temporaryProjects = [];
afterEach(() => {
temporaryProjects.splice(0).forEach(directory => rmSync(directory, { force: true, recursive: true }));
});
test('accepts the documented inward dependency direction', () => {
const fixture = createProject({
'src/core/http/client.ts': 'export const client = true;',
'src/features/instrumentation/api/instrumentation-api.ts':
"import { client } from '@/core/http/client'; export const load = () => client;",
'src/features/instrumentation/model/instrumentation-model.ts': 'export type Guide = { id: string };',
'src/features/instrumentation/controller/instrumentation-controller.ts':
"import { load } from '../api/instrumentation-api'; export const useGuide = load;",
'src/features/instrumentation/components/instrumentation-guide.tsx':
"import type { Guide } from '../model/instrumentation-model'; export const InstrumentationGuide = (_props: { guide: Guide }) => null;",
'src/features/instrumentation/pages/instrumentation-page.tsx':
"import { InstrumentationGuide } from '../components/instrumentation-guide'; import { useGuide } from '../controller/instrumentation-controller'; export const InstrumentationPage = () => <InstrumentationGuide guide={{ id: String(useGuide()) }} />;"
});
const result = cruise(fixture);
assert.equal(result.status, 0, result.output);
});
test('rejects runtime dependencies from API, model, and controller into outer feature layers', () => {
const fixture = createProject({
'src/features/orders/orders-api.ts':
"import { loadOrders } from './orders-controller'; export const invalidApi = loadOrders;",
'src/features/orders/admin/model/orders-model.ts':
"import { requestOrders } from '../../api/orders-request'; export const invalidModel = requestOrders;",
'src/features/orders/api/orders-request.ts': 'export const requestOrders = true;',
'src/features/orders/orders-controller.ts':
"import { OrdersPage } from './orders-page'; export const loadOrders = OrdersPage;",
'src/features/orders/orders-page.tsx': 'export const OrdersPage = () => null;'
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-api-to-outer-feature-layers/);
assert.match(result.output, /no-feature-model-to-non-model-feature-layers/);
assert.match(result.output, /no-feature-controller-to-presentation/);
});
test('rejects type-only dependencies from API, model, and controller into outer feature layers', () => {
const fixture = createProject({
'src/features/orders/api/orders-api.ts':
"import type { OrdersControllerState } from '../controller/orders-controller'; export type InvalidApi = OrdersControllerState;",
'src/features/orders/model/orders-model.ts':
"import type { OrdersRequest } from '../api/orders-request'; export type InvalidModel = OrdersRequest;",
'src/features/orders/api/orders-request.ts': 'export type OrdersRequest = { page: number };',
'src/features/orders/controller/orders-controller.ts':
"import type { OrdersTableProps } from '../components/orders-table'; export type OrdersControllerState = OrdersTableProps;",
'src/features/orders/components/orders-table.tsx':
'export type OrdersTableProps = { rows: string[] }; export const OrdersTable = (_props: OrdersTableProps) => null;'
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-api-to-outer-feature-layers/);
assert.match(result.output, /no-feature-model-to-non-model-feature-layers/);
assert.match(result.output, /no-feature-controller-to-presentation/);
});
test('rejects component dependencies on controllers and pages, including type-only imports', () => {
const fixture = createProject({
'src/features/orders/controller/orders-controller.ts': 'export type OrdersState = { count: number };',
'src/features/orders/pages/orders-page.tsx': 'export const OrdersPage = () => null;',
'src/features/orders/components/orders-table.tsx':
"import type { OrdersState } from '../controller/orders-controller'; export const OrdersTable = (_props: OrdersState) => null;",
'src/features/orders/components/orders-summary.tsx':
"import { OrdersPage } from '../pages/orders-page'; export const OrdersSummary = OrdersPage;",
'src/features/orders/admin/controller/admin-orders-controller.ts':
'export type AdminOrdersState = { count: number };',
'src/features/orders/admin/components/admin-orders-table.tsx':
"import type { AdminOrdersState } from '../controller/admin-orders-controller'; export const AdminOrdersTable = (_props: AdminOrdersState) => null;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-components-to-controller-or-pages/);
assert.match(result.output, /orders-table[.]tsx/);
assert.match(result.output, /orders-summary[.]tsx/);
assert.match(result.output, /admin-orders-table[.]tsx/);
});
test('rejects runtime schema libraries from feature models while allowing API schemas', () => {
const fixture = createProject({
'src/features/orders/api/orders-schema.ts':
"import { z } from 'zod'; export const orderWireSchema = z.object({ id: z.number() });",
'src/features/orders/model/orders-model.ts':
"import { z } from 'zod'; export const invalidDomainSchema = z.object({ id: z.number() });",
'src/features/orders/model/orders-type-model.ts':
"import type { ZodType } from 'zod'; export type InvalidDomainContract = ZodType<{ id: number }> ;",
'src/features/orders/orders-model.ts':
"import { z } from 'zod'; export const invalidRootDomainSchema = z.object({ id: z.number() });"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-model-to-runtime-schema/);
assert.match(result.output, /model\/orders-model[.]ts/);
assert.match(result.output, /model\/orders-type-model[.]ts/);
assert.match(result.output, /orders\/orders-model[.]ts/);
assert.doesNotMatch(result.output, /orders-schema[.]ts.*no-feature-model-to-runtime-schema/);
});
test('rejects reverse layer dependencies and presentation transport access', () => {
const fixture = createProject({
'src/app/router.ts': 'export const router = true;',
'src/shared/time/time.ts': 'export const now = 1;',
'src/features/instrumentation/api/instrumentation-api.ts': 'export const load = true;',
'src/features/instrumentation/controller/instrumentation-controller.ts':
"import { router } from '@/app/router'; export const invalid = router;",
'src/features/instrumentation/components/instrumentation-guide.tsx':
"import { load } from '../api/instrumentation-api'; export const invalid = load;",
'src/core/http/client.ts': "import { now } from '@/shared/time/time'; export const invalid = now;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-to-app-or-layout/);
assert.match(result.output, /no-presentation-to-api/);
assert.match(result.output, /no-core-to-outer-layers/);
});
test('rejects React runtime dependencies from every feature API', () => {
const fixture = createProject({
'src/features/orders/api/orders-api.ts': "import { useMemo } from 'react'; export const invalid = useMemo;",
'src/features/status/public/api/status-api.ts':
"import { useCallback } from 'react'; export const nestedInvalid = useCallback;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-api-to-react-runtime/);
});
test('rejects server-state and navigation runtimes from feature presentation', () => {
const fixture = createProject({
'src/features/orders/components/order-table.tsx':
"import { useQuery } from '@tanstack/react-query'; export const OrderTable = useQuery;",
'src/features/orders/pages/order-page.tsx':
"import { useNavigate } from 'react-router-dom'; export const OrderPage = useNavigate;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-presentation-to-orchestration-runtime/);
});
test('rejects auth session runtimes from feature presentation while allowing controllers', () => {
const coreSessionRuntime = {
'src/core/auth/session-api.ts': 'export const loginSession = () => undefined;',
'src/core/auth/session-context.tsx': 'export const useSession = () => undefined;',
'src/core/auth/session-identity-context.tsx': 'export const useSessionIdentityBoundary = () => undefined;'
};
const allowedFixture = createProject({
...coreSessionRuntime,
'src/features/auth/controller/use-login-controller.ts': [
"import { loginSession } from '@/core/auth/session-api';",
"import { useSession } from '@/core/auth/session-context';",
"import { useSessionIdentityBoundary } from '@/core/auth/session-identity-context';",
'export const useLoginController = () => [loginSession, useSession, useSessionIdentityBoundary];'
].join('\n')
});
const rejectedFixture = createProject({
...coreSessionRuntime,
'src/features/auth/pages/login-page.tsx':
"import { useSession } from '@/core/auth/session-context'; export const LoginPage = useSession;",
'src/features/account/components/session-status.tsx': [
"import { loginSession } from '@/core/auth/session-api';",
"import { useSessionIdentityBoundary } from '@/core/auth/session-identity-context';",
'export const SessionStatus = () => [loginSession, useSessionIdentityBoundary];'
].join('\n')
});
const allowedResult = cruise(allowedFixture);
const rejectedResult = cruise(rejectedFixture);
assert.equal(allowedResult.status, 0, allowedResult.output);
assert.notEqual(rejectedResult.status, 0, rejectedResult.output);
assert.match(rejectedResult.output, /no-feature-presentation-to-auth-session-runtime/);
assert.match(rejectedResult.output, /login-page[.]tsx/);
assert.match(rejectedResult.output, /session-status[.]tsx/);
});
test('rejects Refine resource and HTTP transport dependencies from feature presentation', () => {
const fixture = createProject({
'src/core/http.ts': 'export const request = true;',
'src/features/orders/components/order-table.tsx':
"import { useList } from '@refinedev/core'; export const OrderTable = useList;",
'src/features/orders/pages/order-page.tsx':
"import { request } from '@/core/http'; export const OrderPage = request;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-presentation-to-orchestration-runtime/);
assert.match(result.output, /no-feature-presentation-to-http-transport/);
});
test('rejects orchestration runtimes from legacy feature-root pages', () => {
const fixture = createProject({
'src/features/orders/order-page.tsx':
"import { useQuery } from '@tanstack/react-query'; export const OrderPage = useQuery;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-presentation-to-orchestration-runtime/);
});
test('rejects UI runtime dependencies from feature models', () => {
const fixture = createProject({
'src/features/orders/model/order.ts': "import { useMemo } from 'react'; export const invalidOrderModel = useMemo;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-model-to-ui-runtime/);
});
test('rejects UI runtimes from legacy feature-root models', () => {
const fixture = createProject({
'src/features/orders/order-model.ts': "import { useMemo } from 'react'; export const invalidOrderModel = useMemo;"
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /no-feature-model-to-ui-runtime/);
});
test('rejects alternate production owners for the session provider and authentication gate', () => {
const fixture = createProject({
'src/core/auth/session-provider.tsx': 'export const SessionProvider = () => null;',
'src/core/auth/auth-gate.tsx': 'export const AuthGate = () => null;',
'src/app/refine/refine-runtime.tsx':
"import { SessionProvider } from '@/core/auth/session-provider'; export const Runtime = SessionProvider;",
'src/app/app-routes.tsx': "import { AuthGate } from '@/core/auth/auth-gate'; export const routes = AuthGate;",
'src/layout/shell/alternate-auth-owner.tsx': [
"import { SessionProvider } from '@/core/auth/session-provider';",
"import { AuthGate } from '@/core/auth/auth-gate';",
'export const AlternateAuthOwner = () => <><SessionProvider /><AuthGate /></>;'
].join('\n')
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /session-provider-single-production-owner/);
assert.match(result.output, /auth-gate-single-production-owner/);
});
test('rejects shell header dependencies on action side-effect owners', () => {
const fixture = createProject({
'src/core/auth/session-api.ts': 'export const logoutSession = () => undefined;',
'src/core/runtime-preferences.ts': 'export const persistSystemPreferences = () => undefined;',
'src/layout/shell/shell-header.tsx': [
"import { logoutSession } from '@/core/auth/session-api';",
"import { persistSystemPreferences } from '@/core/runtime-preferences';",
'export const ShellHeader = () => { logoutSession(); persistSystemPreferences(); return null; };'
].join('\n')
});
const result = cruise(fixture);
assert.notEqual(result.status, 0, result.output);
assert.match(result.output, /shell-header-composition-only/);
});
test('keeps settings composition imports on feature public entries', () => {
const allowedFixture = createProject({
'src/features/settings/label/index.ts': 'export const LabelPage = true;',
'src/features/settings/label/refine/index.ts': 'export const labelDataProvider = true;',
'src/features/settings/token/index.ts': 'export const tokenDataProvider = true;',
'src/app/app-routes.tsx': "import { LabelPage } from '@/features/settings/label'; export const route = LabelPage;",
'src/app/refine/refine-runtime.tsx':
"import { labelDataProvider } from '@/features/settings/label/refine'; import { tokenDataProvider } from '@/features/settings/token'; export const providers = [labelDataProvider, tokenDataProvider];"
});
const rejectedFixture = createProject({
'src/features/settings/label/index.ts': 'export const LabelPage = true; export const labelDataProvider = true;',
'src/features/settings/label/pages/label-page.tsx': 'export const LabelPage = true;',
'src/features/settings/token/index.ts': 'export const tokenDataProvider = true;',
'src/features/settings/token/provider/token-data-provider.ts': 'export const tokenDataProvider = true;',
'src/app/app-routes.tsx':
"import { LabelPage } from '@/features/settings/label/pages/label-page'; export const route = LabelPage;",
'src/app/refine/refine-runtime.tsx':
"import { labelDataProvider } from '@/features/settings/label'; import { tokenDataProvider } from '@/features/settings/token/provider/token-data-provider'; export const providers = [labelDataProvider, tokenDataProvider];"
});
const allowedResult = cruise(allowedFixture);
const rejectedResult = cruise(rejectedFixture);
assert.equal(allowedResult.status, 0, allowedResult.output);
assert.notEqual(rejectedResult.status, 0, rejectedResult.output);
assert.match(rejectedResult.output, /settings-router-public-entry-only/);
assert.match(rejectedResult.output, /settings-refine-split-entry-only/);
assert.match(rejectedResult.output, /settings-refine-public-entry-only/);
assert.match(rejectedResult.output, /app-routes[.]tsx/);
assert.match(rejectedResult.output, /refine-runtime[.]tsx/);
});
test('separates Alert route and Refine public entries', () => {
const allowedFixture = createProject({
'src/features/alert/notice-rule/index.ts': 'export const NoticeRulePage = true;',
'src/features/alert/notice-rule/refine/index.ts': 'export const noticeRuleDataProvider = true;',
'src/app/app-routes.tsx':
"import { NoticeRulePage } from '@/features/alert/notice-rule'; export const route = NoticeRulePage;",
'src/app/refine/refine-runtime.tsx':
"import { noticeRuleDataProvider } from '@/features/alert/notice-rule/refine'; export const provider = noticeRuleDataProvider;"
});
const rejectedFixture = createProject({
'src/features/alert/notice-rule/index.ts': 'export const NoticeRulePage = true;',
'src/features/alert/notice-rule/refine/index.ts': 'export const noticeRuleDataProvider = true;',
'src/app/app-routes.tsx':
"import { noticeRuleDataProvider } from '@/features/alert/notice-rule/refine'; export const route = noticeRuleDataProvider;",
'src/app/refine/refine-runtime.tsx':
"import { NoticeRulePage } from '@/features/alert/notice-rule'; export const provider = NoticeRulePage;"
});
const allowedResult = cruise(allowedFixture);
const rejectedResult = cruise(rejectedFixture);
assert.equal(allowedResult.status, 0, allowedResult.output);
assert.notEqual(rejectedResult.status, 0, rejectedResult.output);
assert.match(rejectedResult.output, /alert-router-public-entry-only/);
assert.match(rejectedResult.output, /alert-refine-public-entry-only/);
});
test('keeps Status routes on the feature public entry', () => {
const allowedFixture = createProject({
'src/features/status/index.ts': 'export const PublicStatusPage = true;',
'src/app/app-routes.tsx':
"import { PublicStatusPage } from '@/features/status'; export const route = PublicStatusPage;"
});
const rejectedFixture = createProject({
'src/features/status/index.ts': 'export const PublicStatusPage = true;',
'src/features/status/public/pages/public-status-page.tsx': 'export const PublicStatusPage = true;',
'src/app/app-routes.tsx':
"import { PublicStatusPage } from '@/features/status/public/pages/public-status-page'; export const route = PublicStatusPage;"
});
const allowedResult = cruise(allowedFixture);
const rejectedResult = cruise(rejectedFixture);
assert.equal(allowedResult.status, 0, allowedResult.output);
assert.notEqual(rejectedResult.status, 0, rejectedResult.output);
assert.match(rejectedResult.output, /status-router-public-entry-only/);
});
test('keeps Monitor routes on the feature public entry', () => {
const allowedFixture = createProject({
'src/features/monitor/index.ts': 'export const MonitorListPage = true;',
'src/app/app-routes.tsx':
"import { MonitorListPage } from '@/features/monitor'; export const route = MonitorListPage;"
});
const rejectedFixture = createProject({
'src/features/monitor/index.ts': 'export const MonitorListPage = true;',
'src/features/monitor/pages/monitor-list-page.tsx': 'export const MonitorListPage = true;',
'src/app/app-routes.tsx':
"import { MonitorListPage } from '@/features/monitor/pages/monitor-list-page'; export const route = MonitorListPage;"
});
const allowedResult = cruise(allowedFixture);
const rejectedResult = cruise(rejectedFixture);
assert.equal(allowedResult.status, 0, allowedResult.output);
assert.notEqual(rejectedResult.status, 0, rejectedResult.output);
assert.match(rejectedResult.output, /monitor-router-public-entry-only/);
});
function createProject(files) {
const directory = mkdtempSync(join(tmpdir(), 'hertzbeat-architecture-'));
temporaryProjects.push(directory);
writeFile(
directory,
'tsconfig.app.json',
JSON.stringify({
compilerOptions: {
baseUrl: '.',
jsx: 'react-jsx',
paths: { '@/*': ['src/*'] }
},
include: ['src']
})
);
Object.entries(files).forEach(([path, source]) => writeFile(directory, path, source));
return directory;
}
function writeFile(root, path, content) {
const target = join(root, path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content);
}
function cruise(cwd) {
const result = spawnSync(dependencyCruiser, ['--config', configuration, '--output-type', 'err-long', 'src'], {
cwd,
encoding: 'utf8'
});
return {
output: [result.stdout, result.stderr, result.error?.message].filter(Boolean).join('\n'),
status: result.status
};
}