test(presentation): verify runtime UI wiring and boundaries

This commit is contained in:
2026-09-05 13:12:35 +08:00
parent d29a659541
commit ec67fe6e6d
3 changed files with 557 additions and 28 deletions
@@ -0,0 +1,245 @@
import { readdirSync, readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import {
FRAMEWORK_INTERNAL,
SDK_ALLOWED,
scanImportBoundaries,
} from '../../scripts/lib/import-boundaries.mjs';
const projectRoot = fileURLToPath(new URL('../../', import.meta.url));
const presentationRoot = join(
projectRoot,
'YouleNexus',
'assets',
'framework',
'presentation',
);
function forbiddenSpecifier(specifier) {
return specifier === 'cc' || specifier.startsWith('cc/')
|| /(?:^|\/)(?:net|protocol)(?:\/|$)/.test(specifier)
|| /(?:platform-session|room-rpc-bus)(?:\.|$)/.test(specifier)
|| /(?:^|\/)platform\/session(?:\.|$)/.test(specifier);
}
function permitsTypeOnlyContract(specifier) {
return /(?:^|\/)protocol\/contracts(?:\/|$)/.test(specifier);
}
function staticSpecifier(node) {
return node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)
? node.moduleSpecifier.text
: null;
}
function importIsTypeOnly(node) {
const clause = node.importClause;
if (!clause) return false;
if (clause.isTypeOnly) return true;
return clause.name === undefined
&& clause.namedBindings !== undefined
&& ts.isNamedImports(clause.namedBindings)
&& clause.namedBindings.elements.length > 0
&& clause.namedBindings.elements.every(element => element.isTypeOnly);
}
function exportIsTypeOnly(node) {
if (node.isTypeOnly) return true;
return node.exportClause !== undefined
&& ts.isNamedExports(node.exportClause)
&& node.exportClause.elements.length > 0
&& node.exportClause.elements.every(element => element.isTypeOnly);
}
function externalImportEqualsSpecifier(node) {
if (!ts.isExternalModuleReference(node.moduleReference)
|| !node.moduleReference.expression
|| !ts.isStringLiteralLike(node.moduleReference.expression)) return null;
return node.moduleReference.expression.text;
}
function literalCallSpecifier(node) {
if (node.arguments.length !== 1 || !ts.isStringLiteralLike(node.arguments[0])) return null;
return node.arguments[0].text;
}
function presentationViolations(source, file = 'presentation-inline.ts') {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const violations = [];
const inspect = (specifier, typeOnly, kind) => {
if (!forbiddenSpecifier(specifier)) return;
if (typeOnly && permitsTypeOnlyContract(specifier)) return;
violations.push({ file, specifier, kind });
};
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
const specifier = staticSpecifier(node);
if (specifier !== null) inspect(specifier, importIsTypeOnly(node), 'import');
} else if (ts.isExportDeclaration(node)) {
const specifier = staticSpecifier(node);
if (specifier !== null) inspect(specifier, exportIsTypeOnly(node), 'export');
} else if (ts.isImportEqualsDeclaration(node)) {
const specifier = externalImportEqualsSpecifier(node);
if (specifier !== null) inspect(specifier, false, 'import-equals');
} else if (ts.isImportTypeNode(node)
&& ts.isLiteralTypeNode(node.argument)
&& ts.isStringLiteralLike(node.argument.literal)) {
inspect(node.argument.literal.text, true, 'import-type');
} else if (ts.isCallExpression(node)
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|| (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
const specifier = literalCallSpecifier(node);
if (specifier !== null) {
inspect(specifier, false, 'call');
} else {
violations.push({ file, specifier: '<dynamic>', kind: 'call' });
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
return violations;
}
function typescriptFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? typescriptFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
function sdkBarrelViolations(source, file = 'sdk-index-inline.ts') {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const violations = sourceFile.statements.flatMap((statement) => {
const allowed = ts.isExportDeclaration(statement)
&& !statement.isTypeOnly
&& statement.exportClause === undefined
&& staticSpecifier(statement) === './contracts/index.ts'
&& statement.attributes === undefined;
return allowed ? [] : [{
file,
kind: ts.SyntaxKind[statement.kind],
source: statement.getText(sourceFile),
}];
});
if (violations.length === 0 && sourceFile.statements.length !== 1) {
violations.push({
file,
kind: 'ContractsExportCardinality',
source: String(sourceFile.statements.length),
});
}
return violations;
}
test('presentation boundary recognizes forbidden production imports', () => {
assert.equal(forbiddenSpecifier('../net/wire-client.ts'), true);
assert.equal(forbiddenSpecifier('../protocol/contracts/index.ts'), true);
assert.equal(forbiddenSpecifier('../platform/runtime.ts'), false);
});
test('presentation AST distinguishes pure type contracts from runtime dependencies', () => {
const samples = [
["import type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
["import { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
["type Login = import('../protocol/contracts/index.ts').LoginAccountIdentity;", 0],
["export type { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
["export { type LoginAccountIdentity } from '../protocol/contracts/index.ts';", 0],
["import { LoginAccountIdentity } from '../protocol/contracts/index.ts';", 1],
["import { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1],
["export { type LoginAccountIdentity, buildLoginRequest } from '../protocol/contracts/index.ts';", 1],
["export * from '../protocol/contracts/index.ts';", 1],
["void import('../protocol/contracts/index.ts');", 1],
["void import(target);", 1],
["const contracts = require('../protocol/contracts/index.ts');", 1],
["const contracts = require(target);", 1],
["import protocol = require('../protocol/contracts/index.ts');", 1],
["import type { WireClient } from '../net/wire-client.ts';", 1],
["import type { Node } from 'cc';", 1],
["import { Node } from 'cc';", 1],
["import { PlatformSession } from '../platform/platform-session.ts';", 1],
["import type { PlatformSession } from '../platform/session.ts';", 1],
["const bus = require('../platform/room-rpc-bus.ts');", 1],
["import { parse } from '@framework/protocol';", 1],
["export * from '@framework/net';", 1],
["import type { ScenePort } from '../platform/scene-port.ts';", 0],
];
for (const [source, expected] of samples) {
assert.equal(presentationViolations(source).length, expected, source);
}
});
test('every real presentation source satisfies the value-dependency boundary', () => {
const violations = typescriptFiles(presentationRoot).flatMap(file => (
presentationViolations(readFileSync(file, 'utf8'), file)
));
assert.deepEqual(violations, []);
});
test('SDK public barrel exports contracts only', () => {
const sdkIndex = join(projectRoot, 'YouleNexus', 'assets', 'framework', 'sdk', 'index.ts');
assert.deepEqual(sdkBarrelViolations(readFileSync(sdkIndex, 'utf8'), sdkIndex), []);
});
test('SDK contracts-only gate rejects a local exported declaration', () => {
const source = 'export const unexpectedSdkApi = 1;';
assert.equal(sdkBarrelViolations(source).length, 1);
});
test('SDK contracts-only gate requires exactly one contracts re-export', () => {
assert.ok(sdkBarrelViolations('').length > 0);
assert.ok(sdkBarrelViolations([
"export * from './contracts/index.ts';",
"export * from './contracts/index.ts';",
].join('\n')).length > 0);
});
test('SDK contracts-only gate rejects alternate exports and import-type leaks', () => {
const forbiddenSources = [
'const privateHelper = 1;',
'export function unexpectedSdkApi() {}',
'export default class UnexpectedSdkApi {}',
'export interface UnexpectedSdkApi {}',
'export type UnexpectedSdkApi = string;',
"export { GameEntry } from './contracts/index.ts';",
"export type { GameEntry } from './contracts/index.ts';",
"export * as contracts from './contracts/index.ts';",
"export * from '../presentation/ui-contracts.ts';",
"type Leaked = import('../presentation/ui-contracts.ts').PageModel; export type { Leaked };",
"import type { PageModel } from '../presentation/ui-contracts.ts'; export type { PageModel };",
"export * from './contracts/index.ts'; export const unexpectedSdkApi = 1;",
];
assert.deepEqual(sdkBarrelViolations("export * from './contracts/index.ts';"), []);
for (const source of forbiddenSources) {
assert.ok(sdkBarrelViolations(source).length > 0, source);
}
});
test('general architecture scanner keeps presentation internal to framework and out of SDK', () => {
assert.equal(FRAMEWORK_INTERNAL.test('framework/presentation/platform-ui-controller.ts'), true);
assert.equal(SDK_ALLOWED.test('framework/presentation/platform-ui-controller.ts'), false);
const violations = scanImportBoundaries({
frameworkDir: join(projectRoot, 'YouleNexus', 'assets', 'framework'),
gamesDir: join(projectRoot, 'games'),
tsconfigPath: join(projectRoot, 'tsconfig.framework.json'),
});
assert.deepEqual(violations, []);
});