Warning, /firebird/packages/firebird-core/src/no-injector.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * firebird-core must work with no Angular injector and no bootstrap - the
0003 * geometry-loader web worker runs this code, and workers have no DI (event
0004 * parsing is expected to move off the main thread the same way). This spec
0005 * is the enforced form of that constraint, in two parts:
0006 * - it parses a DEX file and paints it into a bare three.js Scene using only
0007 * core classes, so a dependency that needs an injector at run time fails;
0008 * - it scans every non-spec source file for Angular DI and component APIs
0009 * (InjectionToken, Injectable, inject(), HttpClient, component decorators),
0010 * so a statically present token fails even when no code path runs it.
0011 * Angular signals (`signal`, `Signal`, `computed`) stay allowed: they work
0012 * without an injector.
0013 */
0014 import { readdirSync, readFileSync } from 'node:fs';
0015 import { join, relative } from 'node:path';
0016 import { Scene } from 'three';
0017 import { DataExchange, DataModelPainter, initPieceFactories, registerDefaultPainters } from './index';
0018
0019 const DEX_SAMPLE = {
0020 type: 'firebird-dex-json',
0021 version: '1.0',
0022 origin: { by: 'no-injector.spec' },
0023 events: [
0024 {
0025 id: 'event_0',
0026 pieces: [
0027 {
0028 name: 'TestHits',
0029 type: 'BoxHit',
0030 version: '1.0',
0031 origin: { by: 'spec' },
0032 count: 2,
0033 columns: {
0034 pos: [10, 20, 30, 40, 50, 60],
0035 dim: [1, 1, 1, 2, 2, 2],
0036 time: [0, 5],
0037 edep: [0.001, 0.002],
0038 },
0039 },
0040 {
0041 name: 'TestTracks',
0042 type: 'PointTrajectory',
0043 version: '1.0',
0044 origin: { by: 'spec' },
0045 count: 1,
0046 columns: { theta: [0.5] },
0047 pointColumns: ['x', 'y', 'z', 't'],
0048 points: [
0049 [[0, 0, 0, 0], [10, 10, 10, 1], [20, 15, 30, 2]],
0050 ],
0051 },
0052 ],
0053 },
0054 ],
0055 };
0056
0057 describe('firebird-core without an injector', () => {
0058 it('parses DEX and paints an event into a bare Scene', () => {
0059 // Registration is explicit — no import side effects, no DI (this is how
0060 // the workers wire core; the Angular app wires the same classes via tokens).
0061 initPieceFactories();
0062
0063 const dex = DataExchange.fromDexObj(DEX_SAMPLE);
0064 expect(dex.events.length).toBe(1);
0065 expect(dex.events[0].pieces.length).toBe(2);
0066
0067 const scene = new Scene();
0068 const painter = new DataModelPainter();
0069 registerDefaultPainters(painter);
0070 painter.setThreeSceneParent(scene);
0071 painter.setEntry(dex.events[0]);
0072 painter.paint(null);
0073
0074 // The painter must have created three.js objects under the scene.
0075 let meshCount = 0;
0076 scene.traverse(() => meshCount++);
0077 expect(meshCount).toBeGreaterThan(2);
0078
0079 // Time filtering runs without any framework machinery either.
0080 painter.paint(1.5);
0081 });
0082 });
0083
0084 // Angular APIs that need an injector or a compiled component, with the name
0085 // the scan reports for each.
0086 const FORBIDDEN_APIS: ReadonlyArray<readonly [string, RegExp]> = [
0087 ['InjectionToken', /\bInjectionToken\b/],
0088 ['Injectable', /\bInjectable\b/],
0089 ['inject()', /\binject\s*\(/],
0090 ['HttpClient', /\bHttpClient\b/],
0091 ['component decorator', /@(?:Component|Directive|Pipe|NgModule)\s*\(/],
0092 ];
0093
0094 /** Removes comments, so prose that names a forbidden API does not count. Keeps `://` in URLs. */
0095 function stripComments(source: string): string {
0096 return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
0097 }
0098
0099 /** Names of the forbidden APIs that a TypeScript source uses outside comments. */
0100 function findForbiddenApis(source: string): string[] {
0101 const code = stripComments(source);
0102 return FORBIDDEN_APIS.filter(([, pattern]) => pattern.test(code)).map(([name]) => name);
0103 }
0104
0105 /** Every non-spec .ts file under `dir`, recursively. */
0106 function sourceFiles(dir: string): string[] {
0107 return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
0108 const path = join(dir, entry.name);
0109 if (entry.isDirectory()) {
0110 return sourceFiles(path);
0111 }
0112 return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') ? [path] : [];
0113 });
0114 }
0115
0116 describe('firebird-core source scan', () => {
0117 // vitest defines __dirname for spec modules; the jsdom environment's
0118 // import.meta.url is not a file: URL.
0119 const srcDir = __dirname;
0120 const files = sourceFiles(srcDir);
0121
0122 it('finds the package sources', () => {
0123 // Guards against a vacuous pass when the directory walk finds nothing.
0124 expect(files.length).toBeGreaterThan(10);
0125 expect(files.map(file => relative(srcDir, file))).toContain('index.ts');
0126 });
0127
0128 it('uses no Angular DI, HttpClient or component API', () => {
0129 const offenders = files.flatMap(file =>
0130 findForbiddenApis(readFileSync(file, 'utf8')).map(api => `${relative(srcDir, file)}: ${api}`));
0131 expect(offenders).toEqual([]);
0132 });
0133
0134 it('detects each forbidden API and allows signals and comments', () => {
0135 expect(findForbiddenApis("import { InjectionToken } from '@angular/core';")).toEqual(['InjectionToken']);
0136 expect(findForbiddenApis("@Injectable({ providedIn: 'root' }) export class S {}")).toEqual(['Injectable']);
0137 expect(findForbiddenApis('const http = inject(HttpClient);')).toEqual(['inject()', 'HttpClient']);
0138 expect(findForbiddenApis('@Component({ template: "" }) export class C {}')).toEqual(['component decorator']);
0139 expect(findForbiddenApis("import { Signal, computed, signal } from '@angular/core';")).toEqual([]);
0140 expect(findForbiddenApis('// @Injectable, inject(), HttpClient\n/* InjectionToken */')).toEqual([]);
0141 expect(findForbiddenApis("const url = 'https://example.org'; const t = new InjectionToken('t');"))
0142 .toEqual(['InjectionToken']);
0143 });
0144 });