fix(platform): serialize runtime session transitions
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { afterEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs';
|
||||
import {
|
||||
findCompatibilityDependency,
|
||||
scanImportBoundaries,
|
||||
} from '../../scripts/lib/import-boundaries.mjs';
|
||||
|
||||
const roots = [];
|
||||
|
||||
@@ -55,45 +57,13 @@ const legacyRuntimeFiles = new Set([
|
||||
'protocol/room-handlers.ts',
|
||||
].map((file) => resolve(frameworkRoot, file)));
|
||||
|
||||
function relativeImports(file) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
|
||||
const imports = [];
|
||||
const visit = (node) => {
|
||||
let specifier;
|
||||
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
|
||||
&& node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
specifier = node.moduleSpecifier.text;
|
||||
} else if (ts.isImportTypeNode(node)
|
||||
&& ts.isLiteralTypeNode(node.argument)
|
||||
&& ts.isStringLiteral(node.argument.literal)) {
|
||||
specifier = node.argument.literal.text;
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
&& node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) {
|
||||
specifier = node.arguments[0].text;
|
||||
}
|
||||
if (specifier?.startsWith('.')) {
|
||||
const imported = resolve(dirname(file), specifier);
|
||||
const candidates = [imported, `${imported}.ts`, join(imported, 'index.ts')];
|
||||
const resolvedImport = candidates.find((candidate) => existsSync(candidate));
|
||||
if (resolvedImport) imports.push(resolvedImport);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
return imports;
|
||||
}
|
||||
|
||||
function findLegacyDependency(entry, path = [], visited = new Set()) {
|
||||
if (legacyRuntimeFiles.has(entry)) return [...path, entry];
|
||||
if (visited.has(entry)) return null;
|
||||
visited.add(entry);
|
||||
for (const imported of relativeImports(entry)) {
|
||||
const found = findLegacyDependency(imported, [...path, entry], visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
function scanCompatibilityFixture(root, entry = 'framework/protocol/router.ts', tsconfigPath) {
|
||||
return findCompatibilityDependency({
|
||||
entry: join(root, entry),
|
||||
forbiddenFiles: [join(root, 'framework/platform/session.ts')],
|
||||
traversalRoot: join(root, 'framework'),
|
||||
tsconfigPath: tsconfigPath ? join(root, tsconfigPath) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
|
||||
@@ -103,15 +73,114 @@ test('every existing modern runtime entry is transitively isolated from the lega
|
||||
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
|
||||
|
||||
for (const entry of entries) {
|
||||
const dependencyPath = findLegacyDependency(entry);
|
||||
const dependency = findCompatibilityDependency({
|
||||
entry,
|
||||
forbiddenFiles: [...legacyRuntimeFiles],
|
||||
traversalRoot: frameworkRoot,
|
||||
tsconfigPath: fileURLToPath(new URL('../../tsconfig.framework.json', import.meta.url)),
|
||||
});
|
||||
assert.equal(
|
||||
dependencyPath,
|
||||
dependency,
|
||||
null,
|
||||
dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
|
||||
dependency?.path.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => {
|
||||
const cases = [
|
||||
"import '../platform/session.ts'\n",
|
||||
"export * from '../platform/session.ts'\n",
|
||||
"export type { LegacySession } from '../platform/session.ts'\n",
|
||||
"type Legacy = import('../platform/session.ts').LegacySession\n",
|
||||
];
|
||||
|
||||
for (const source of cases) {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/platform/session.ts', 'export interface LegacySession {}\n');
|
||||
await writeFixture(root, 'framework/protocol/barrel.ts', source);
|
||||
await writeFixture(root, 'framework/protocol/router.ts', "export * from './barrel.ts'\n");
|
||||
|
||||
const found = scanCompatibilityFixture(root);
|
||||
assert.equal(found?.kind, 'forbidden', source);
|
||||
assert.deepEqual(
|
||||
found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]),
|
||||
['protocol/router.ts', 'protocol/barrel.ts', 'platform/session.ts'],
|
||||
source,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('compatibility graph reports direct and ordinary transitive dependency paths', async () => {
|
||||
const directRoot = await createFixtureRoot();
|
||||
await writeFixture(directRoot, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(directRoot, 'framework/protocol/router.ts', "import '../platform/session.ts'\n");
|
||||
assert.equal(scanCompatibilityFixture(directRoot)?.path.length, 2);
|
||||
|
||||
const transitiveRoot = await createFixtureRoot();
|
||||
await writeFixture(transitiveRoot, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(transitiveRoot, 'framework/protocol/helper.ts', "import '../platform/session.ts'\n");
|
||||
await writeFixture(transitiveRoot, 'framework/protocol/router.ts', "import './helper.ts'\n");
|
||||
assert.equal(scanCompatibilityFixture(transitiveRoot)?.path.length, 3);
|
||||
});
|
||||
|
||||
test('compatibility graph catches string and no-substitution-template dynamic imports', async () => {
|
||||
for (const source of [
|
||||
"await import('../platform/session.ts')\n",
|
||||
'await import(`../platform/session.ts`)\n',
|
||||
]) {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(root, 'framework/protocol/router.ts', source);
|
||||
|
||||
assert.equal(scanCompatibilityFixture(root)?.kind, 'forbidden', source);
|
||||
}
|
||||
});
|
||||
|
||||
test('compatibility graph resolves tsconfig path aliases to canonical files', async () => {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(root, 'framework/protocol/router.ts', "import '@legacy/session'\n");
|
||||
await writeFixture(root, 'tsconfig.json', JSON.stringify({
|
||||
compilerOptions: {
|
||||
baseUrl: '.',
|
||||
paths: { '@legacy/*': ['framework/platform/*'] },
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
allowImportingTsExtensions: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const found = scanCompatibilityFixture(root, undefined, 'tsconfig.json');
|
||||
assert.equal(found?.kind, 'forbidden');
|
||||
assert.ok(found?.path.at(-1)?.replaceAll('\\', '/').endsWith('/framework/platform/session.ts'));
|
||||
});
|
||||
|
||||
test('compatibility graph fails closed on a reachable non-static dynamic import', async () => {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(
|
||||
root,
|
||||
'framework/protocol/router.ts',
|
||||
"const target = '../platform/session.ts'; void import(target)\n",
|
||||
);
|
||||
|
||||
const found = scanCompatibilityFixture(root);
|
||||
assert.equal(found?.kind, 'dynamic');
|
||||
assert.ok(found?.path[0]?.replaceAll('\\', '/').endsWith('/framework/protocol/router.ts'));
|
||||
});
|
||||
|
||||
test('compatibility graph applies the same isolation to a future platform/runtime.ts entry', async () => {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/platform/session.ts', 'export const legacy = true\n');
|
||||
await writeFixture(root, 'framework/application/barrel.ts', "export * from '../platform/session.ts'\n");
|
||||
await writeFixture(root, 'framework/platform/runtime.ts', "import '../application/barrel.ts'\n");
|
||||
|
||||
const found = scanCompatibilityFixture(root, 'framework/platform/runtime.ts');
|
||||
assert.equal(found?.kind, 'forbidden');
|
||||
assert.equal(found?.path.length, 3);
|
||||
});
|
||||
|
||||
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
||||
const root = await createFixtureRoot();
|
||||
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
||||
|
||||
Reference in New Issue
Block a user